github.com/decred/politeia@v1.4.0/politeiad/cmd/legacypoliteia/cmd_convert.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 "bufio" 9 "bytes" 10 "encoding/json" 11 "errors" 12 "flag" 13 "fmt" 14 "io" 15 "net/http" 16 "os" 17 "path/filepath" 18 "sort" 19 "strconv" 20 "sync" 21 22 backend "github.com/decred/politeia/politeiad/backendv2" 23 "github.com/decred/politeia/politeiad/cmd/legacypoliteia/gitbe" 24 "github.com/decred/politeia/politeiad/plugins/comments" 25 "github.com/decred/politeia/politeiad/plugins/pi" 26 "github.com/decred/politeia/politeiad/plugins/ticketvote" 27 "github.com/decred/politeia/politeiad/plugins/usermd" 28 v1 "github.com/decred/politeia/politeiawww/api/ticketvote/v1" 29 "github.com/decred/politeia/politeiawww/client" 30 "github.com/decred/politeia/util" 31 ) 32 33 const ( 34 // defaultLegacyDir is the default directory that the converted legacy data 35 // is saved to. 36 defaultLegacyDir = "./legacy-politeia-data" 37 ) 38 39 var ( 40 // CLI flags for the convert command. We print a custom usage message, 41 // see usage.go, so the individual flag usage messages are left blank. 42 convertFlags = flag.NewFlagSet(convertCmdName, flag.ContinueOnError) 43 legacyDir = convertFlags.String("legacydir", defaultLegacyDir, "") 44 convertToken = convertFlags.String("token", "", "") 45 overwrite = convertFlags.Bool("overwrite", false, "") 46 ) 47 48 // execConvertComd executes the convert command. 49 // 50 // The convert command parses a legacy git repo, converts the data into types 51 // supported by the tstore backend, then writes the converted JSON data to 52 // disk. This data can be imported into tstore using the 'import' command. 53 func execConvertCmd(args []string) error { 54 // Verify the git repo exists 55 if len(args) == 0 { 56 return fmt.Errorf("missing git repo argument") 57 } 58 gitRepo := util.CleanAndExpandPath(args[0]) 59 if _, err := os.Stat(gitRepo); err != nil { 60 return fmt.Errorf("git repo not found: %v", gitRepo) 61 } 62 63 // Parse the CLI flags 64 err := convertFlags.Parse(args[1:]) 65 if err != nil { 66 return err 67 } 68 69 // Clean the legacy directory path 70 *legacyDir = util.CleanAndExpandPath(*legacyDir) 71 72 // Setup the legacy directory 73 err = os.MkdirAll(*legacyDir, filePermissions) 74 if err != nil { 75 return err 76 } 77 78 client, err := util.NewHTTPClient(false, "") 79 if err != nil { 80 return err 81 } 82 83 // Setup the cmd context 84 c := convertCmd{ 85 client: client, 86 gitRepo: gitRepo, 87 legacyDir: *legacyDir, 88 token: *convertToken, 89 overwrite: *overwrite, 90 userIDs: make(map[string]string, 1024), 91 } 92 93 // Convert the legacy proposals 94 return c.convertLegacyProposals() 95 } 96 97 // convertCmd represents the convert CLI command. 98 type convertCmd struct { 99 sync.Mutex 100 client *http.Client 101 gitRepo string 102 legacyDir string 103 token string 104 overwrite bool 105 106 // userIDs is used to memoize user ID by public key lookups, which require 107 // querying the politeia API. 108 userIDs map[string]string // [pubkey]userID 109 } 110 111 // convertLegacyProposals converts the legacy git backend proposals to tstore 112 // backend proposals then the converted proposals to disk as JSON encoded 113 // files. These converted proposals can be imported into a tstore backend using 114 // the import command. 115 func (c *convertCmd) convertLegacyProposals() error { 116 // Build an inventory of all legacy proposal tokens 117 tokens, err := parseProposalTokens(c.gitRepo) 118 if err != nil { 119 return err 120 } 121 122 fmt.Printf("Found %v legacy git proposals\n", len(tokens)) 123 124 // Convert the data for each proposal into tstore supported 125 // types then save the converted proposal to disk. 126 for i, token := range tokens { 127 switch { 128 case c.token != "" && c.token != token: 129 // The caller only wants to convert a single 130 // proposal and this is not it. Skip it. 131 continue 132 133 case c.token != "" && c.token == token: 134 // The caller only wants to convert a single 135 // proposal and this is it. Convert it. 136 fmt.Printf("Converting proposal %v\n", token) 137 138 default: 139 // All proposals are being converted 140 fmt.Printf("Converting proposal %v (%v/%v)\n", 141 token, i+1, len(tokens)) 142 } 143 144 // Skip the conversion if the converted proposal 145 // already exists on disk. 146 exists, err := proposalExists(c.legacyDir, token) 147 if err != nil { 148 return err 149 } 150 if exists && !c.overwrite { 151 fmt.Printf("Proposal has already been converted; skipping\n") 152 continue 153 } 154 155 // Get the path to the most recent version of the 156 // proposal. We only import the most recent version. 157 // 158 // Example path: [gitRepo]/[token]/[version]/ 159 v, err := parseLatestProposalVersion(c.gitRepo, token) 160 if err != nil { 161 return err 162 } 163 proposalDir := filepath.Join(c.gitRepo, token, strconv.FormatUint(v, 10)) 164 165 // Convert git backend types to tstore backend types 166 recordMD, err := c.convertRecordMetadata(proposalDir) 167 if err != nil { 168 return err 169 } 170 files, err := c.convertFiles(proposalDir) 171 if err != nil { 172 return err 173 } 174 proposalMD, err := c.convertProposalMetadata(proposalDir) 175 if err != nil { 176 return err 177 } 178 voteMD, err := c.convertVoteMetadata(proposalDir) 179 if err != nil { 180 return err 181 } 182 userMD, err := c.convertUserMetadata(proposalDir) 183 if err != nil { 184 return err 185 } 186 statusChanges, err := c.convertStatusChanges(proposalDir) 187 if err != nil { 188 return err 189 } 190 ct, err := c.convertComments(proposalDir) 191 if err != nil { 192 return err 193 } 194 var ( 195 authDetails *ticketvote.AuthDetails 196 voteDetails *ticketvote.VoteDetails 197 castVotes []ticketvote.CastVoteDetails 198 ) 199 switch { 200 case recordMD.Status != backend.StatusPublic: 201 // Only proposals with a public status will have vote 202 // data that needs to be converted. This proposal does 203 // not have a public status so we can skip this part. 204 205 default: 206 // This proposal has vote data that needs to be converted 207 authDetails, err = c.convertAuthDetails(proposalDir) 208 if err != nil { 209 return err 210 } 211 voteDetails, err = c.convertVoteDetails(proposalDir, voteMD) 212 if err != nil { 213 return err 214 } 215 castVotes, err = c.convertCastVotes(proposalDir) 216 if err != nil { 217 return err 218 } 219 } 220 221 // Build the proposal 222 p := proposal{ 223 RecordMetadata: *recordMD, 224 Files: files, 225 ProposalMetadata: *proposalMD, 226 VoteMetadata: voteMD, 227 UserMetadata: *userMD, 228 StatusChanges: statusChanges, 229 CommentAdds: ct.Adds, 230 CommentDels: ct.Dels, 231 CommentVotes: ct.Votes, 232 AuthDetails: authDetails, 233 VoteDetails: voteDetails, 234 CastVotes: castVotes, 235 } 236 err = verifyProposal(p) 237 if err != nil { 238 return err 239 } 240 241 // write the proposal to disk 242 err = writeProposal(c.legacyDir, p) 243 if err != nil { 244 return err 245 } 246 } 247 248 fmt.Printf("Legacy proposal conversion complete\n") 249 250 return nil 251 } 252 253 // convertRecordMetadata reads the git backend RecordMetadata from disk for 254 // the provided proposal and converts it into a tstore backend RecordMetadata. 255 func (c *convertCmd) convertRecordMetadata(proposalDir string) (*backend.RecordMetadata, error) { 256 fmt.Printf(" RecordMetadata\n") 257 258 // Read the git backend record metadata from disk 259 fp := recordMetadataPath(proposalDir) 260 b, err := os.ReadFile(fp) 261 if err != nil { 262 return nil, err 263 } 264 265 var r gitbe.RecordMetadata 266 err = json.Unmarshal(b, &r) 267 if err != nil { 268 return nil, err 269 } 270 271 // The version number can be found in the proposal 272 // file path. It is the last directory in the path. 273 v := filepath.Base(proposalDir) 274 version, err := strconv.ParseUint(v, 10, 32) 275 if err != nil { 276 return nil, err 277 } 278 279 // Convert the record metadata 280 rm := convertRecordMetadata(r, uint32(version)) 281 282 fmt.Printf(" Token : %v\n", rm.Token) 283 fmt.Printf(" Version : %v\n", rm.Version) 284 fmt.Printf(" Iteration: %v\n", rm.Iteration) 285 fmt.Printf(" State : %v\n", backend.States[rm.State]) 286 fmt.Printf(" Status : %v\n", backend.Statuses[rm.Status]) 287 fmt.Printf(" Timestamp: %v\n", rm.Timestamp) 288 fmt.Printf(" Merkle : %v\n", rm.Merkle) 289 290 return &rm, nil 291 } 292 293 // convertFiles reads all of the git backend proposal index file and image 294 // attachments from disk for the provided proposal and converts them to tstore 295 // backend files. 296 func (c *convertCmd) convertFiles(proposalDir string) ([]backend.File, error) { 297 fmt.Printf(" Files\n") 298 299 files := make([]backend.File, 0, 64) 300 301 // Read the index file from disk 302 fp := indexFilePath(proposalDir) 303 b, err := os.ReadFile(fp) 304 if err != nil { 305 return nil, err 306 } 307 files = append(files, convertFile(b, pi.FileNameIndexFile)) 308 309 fmt.Printf(" %v\n", pi.FileNameIndexFile) 310 311 // Read any image attachments from disk 312 attachments, err := parseProposalAttachmentFilenames(proposalDir) 313 if err != nil { 314 return nil, err 315 } 316 for _, fn := range attachments { 317 fp := attachmentFilePath(proposalDir, fn) 318 b, err := os.ReadFile(fp) 319 if err != nil { 320 return nil, err 321 } 322 323 files = append(files, convertFile(b, fn)) 324 325 fmt.Printf(" %v\n", fn) 326 } 327 328 return files, nil 329 } 330 331 // convertProposalMetadata reads the git backend data from disk that is 332 // required to build the pi plugin ProposalMetadata structure, then returns the 333 // ProposalMetadata. 334 func (c *convertCmd) convertProposalMetadata(proposalDir string) (*pi.ProposalMetadata, error) { 335 fmt.Printf(" Proposal metadata\n") 336 337 // The only data we need to pull from the legacy 338 // proposal is the proposal name. The name will 339 // always be the first line of the proposal index 340 // file. 341 name, err := parseProposalName(proposalDir) 342 if err != nil { 343 return nil, err 344 } 345 346 pm := convertProposalMetadata(name) 347 348 fmt.Printf(" Name : %v\n", pm.Name) 349 350 return &pm, nil 351 } 352 353 // convertVoteMetadata reads the git backend data from disk that is required to 354 // build a ticketvote plugin VoteMetadata structure, then returns the 355 // VoteMetadata. 356 func (c *convertCmd) convertVoteMetadata(proposalDir string) (*ticketvote.VoteMetadata, error) { 357 fmt.Printf(" Vote metadata\n") 358 359 // The vote metadata fields are in the gitbe 360 // proposal metadata payload file. This file 361 // will only exist for some gitbe proposals. 362 fp := proposalMetadataPath(proposalDir) 363 if _, err := os.Stat(fp); err != nil { 364 switch { 365 case errors.Is(err, os.ErrNotExist): 366 // File does not exist 367 return nil, nil 368 369 default: 370 // Unknown error 371 return nil, err 372 } 373 } 374 375 // Read the proposal metadata file from disk 376 b, err := os.ReadFile(fp) 377 if err != nil { 378 return nil, err 379 } 380 381 var pm gitbe.ProposalMetadata 382 err = json.Unmarshal(b, &pm) 383 if err != nil { 384 return nil, err 385 } 386 387 // A VoteMetadata only needs to be built if the proposal 388 // contains fields that indicate that it's either an RFP 389 // or RFP submissions. These are the LinkBy and LinkTo 390 // fields. 391 if pm.LinkBy == 0 && pm.LinkTo == "" { 392 // We don't need a VoteMetadata for this proposal 393 return nil, nil 394 } 395 396 // Build the vote metadata 397 vm := convertVoteMetadata(pm) 398 399 fmt.Printf(" Link by: %v\n", vm.LinkBy) 400 fmt.Printf(" Link to: %v\n", vm.LinkTo) 401 402 return &vm, nil 403 } 404 405 // convertUserMetadata reads the git backend data from disk that is required to 406 // build a usermd plugin UserMetadata structure, then returns the UserMetadata. 407 // 408 // This function makes an external API call to the politeia API to retrieve the 409 // user ID. 410 func (c *convertCmd) convertUserMetadata(proposalDir string) (*usermd.UserMetadata, error) { 411 fmt.Printf(" User metadata\n") 412 413 // Read the proposal general mdstream from disk 414 fp := proposalGeneralPath(proposalDir) 415 b, err := os.ReadFile(fp) 416 if err != nil { 417 return nil, err 418 } 419 420 // We can decode both the v1 and v2 proposal general 421 // metadata stream into the ProposalGeneralV2 struct 422 // since the fields we need from it are present in 423 // both versions. 424 var p gitbe.ProposalGeneralV2 425 err = json.Unmarshal(b, &p) 426 if err != nil { 427 return nil, err 428 } 429 430 // Populate the user ID. The user ID was not saved 431 // to disk in the git backend, so we must retrieve 432 // it from the politeia API using the public key. 433 userID, err := c.userIDByPubKey(p.PublicKey) 434 if err != nil { 435 return nil, err 436 } 437 438 // Build the user metadata 439 um := convertUserMetadata(p, userID) 440 441 fmt.Printf(" User ID : %v\n", um.UserID) 442 fmt.Printf(" PublicKey: %v\n", um.PublicKey) 443 fmt.Printf(" Signature: %v\n", um.Signature) 444 445 return &um, nil 446 } 447 448 // convertStatusChanges reads the git backend data from disk that is required 449 // to build the usermd plugin StatusChangeMetadata structures, then returns 450 // the StateChangeMetadata that is found. 451 // 452 // A public proposal will only have one status change returned. The status 453 // change of when the proposal was made public. 454 // 455 // An abandoned proposal will have two status changes returned. The status 456 // change from when the proposal was made public and the status change from 457 // when the proposal was marked as abandoned. 458 // 459 // All other status changes are not public data and thus will not have been 460 // included in the legacy git repo. 461 func (c *convertCmd) convertStatusChanges(proposalDir string) ([]usermd.StatusChangeMetadata, error) { 462 fmt.Printf(" Status changes\n") 463 464 // Read the status changes mdstream from disk 465 fp := statusChangesPath(proposalDir) 466 b, err := os.ReadFile(fp) 467 if err != nil { 468 return nil, err 469 } 470 471 // Parse the token and version from the proposal dir path 472 token, ok := parseProposalToken(proposalDir) 473 if !ok { 474 return nil, fmt.Errorf("token not found in path '%v'", proposalDir) 475 } 476 version, err := parseProposalVersion(proposalDir) 477 if err != nil { 478 return nil, err 479 } 480 481 // The git backend v1 status change struct does not have the 482 // signature included. This is the only difference between 483 // v1 and v2, so we decode all of them into the v2 structure. 484 var ( 485 statuses = make([]usermd.StatusChangeMetadata, 0, 16) 486 decoder = json.NewDecoder(bytes.NewReader(b)) 487 ) 488 for { 489 var sc gitbe.RecordStatusChangeV2 490 err := decoder.Decode(&sc) 491 if errors.Is(err, io.EOF) { 492 break 493 } else if err != nil { 494 return nil, err 495 } 496 497 statuses = append(statuses, convertStatusChange(sc, token, version)) 498 } 499 500 // Sort status changes from oldest to newest 501 sort.SliceStable(statuses, func(i, j int) bool { 502 return statuses[i].Timestamp < statuses[j].Timestamp 503 }) 504 505 // Sanity checks 506 switch { 507 case len(statuses) == 0: 508 return nil, fmt.Errorf("no status changes found") 509 case len(statuses) > 2: 510 return nil, fmt.Errorf("invalid number of status changes (%v)", 511 len(statuses)) 512 } 513 for _, v := range statuses { 514 switch v.Status { 515 case 2: 516 // Public status. This is expected. 517 case 4: 518 // Abandoned status. This is expected. 519 default: 520 return nil, fmt.Errorf("invalid status %v", v.Status) 521 } 522 } 523 524 // Print the status changes 525 for i, v := range statuses { 526 status := backend.Statuses[backend.StatusT(v.Status)] 527 fmt.Printf(" Token : %v\n", v.Token) 528 fmt.Printf(" Version : %v\n", v.Version) 529 fmt.Printf(" Status : %v\n", status) 530 fmt.Printf(" PublicKey: %v\n", v.PublicKey) 531 fmt.Printf(" Signature: %v\n", v.Signature) 532 fmt.Printf(" Reason : %v\n", v.Reason) 533 fmt.Printf(" Timestamp: %v\n", v.Timestamp) 534 535 if i != len(statuses)-1 { 536 fmt.Printf(" ----\n") 537 } 538 } 539 540 return statuses, nil 541 } 542 543 // commentTypes contains the various comment data types for a proposal. 544 type commentTypes struct { 545 Adds []comments.CommentAdd 546 Dels []comments.CommentDel 547 Votes []comments.CommentVote 548 } 549 550 // convertComments converts a legacy proposal's comment data from git backend 551 // types to tstore backend types. This process included reading the comments 552 // journal from disk, converting the comment types, and retrieving the user ID 553 // from politeia for each comment and comment vote. 554 // 555 // Note, the comment signature messages changed between the git backend and the 556 // tstore backend. 557 func (c *convertCmd) convertComments(proposalDir string) (*commentTypes, error) { 558 fmt.Printf(" Comments\n") 559 560 // Open the comments journal 561 fp := commentsJournalPath(proposalDir) 562 f, err := os.Open(fp) 563 if err != nil { 564 return nil, err 565 } 566 defer f.Close() 567 568 // Read the journal line-by-line and decode the payloads 569 var ( 570 scanner = bufio.NewScanner(f) 571 572 // The legacy proposals may contain duplicate comments. 573 // We DO NOT filter these duplicates out because of the 574 // errors it can cause: 575 // 576 // - Some of the duplicate comments have comment votes 577 // on both of the comments. Deleting one causes issues 578 // where a comment vote no longer references a valid 579 // comment ID. 580 // 581 // - The backend assumes that the comment IDs will be 582 // sequential. It will throw errors if something is 583 // off. There needs to be either a comment add entry 584 // or a comment del entry for each sequential comment 585 // ID. 586 // 587 // We DO filter out duplicate comment votes. There is no 588 // unique piece of data on a duplicate comment vote like 589 // there is on a duplicate comment, i.e. the comment ID, 590 // which means that duplicate comment votes will cause a 591 // trillian duplicate leaf error when attempting to import 592 // the duplicate comment vote into the tstore backend. 593 adds = make(map[string]comments.CommentAdd) // [commentID]CommentAdd 594 dels = make(map[string]comments.CommentDel) // [commentID]CommentDel 595 votes = make(map[string]comments.CommentVote) // [signature]CommentVote 596 597 // We must track the parent IDs for new comments 598 // because the gitbe censore comment struct does 599 // include the parent ID, but the comments plugin 600 // del struct does. 601 parentIDs = make(map[string]uint32) // [commentID]parentID 602 ) 603 for scanner.Scan() { 604 // Decode the current line 605 r := bytes.NewReader(scanner.Bytes()) 606 d := json.NewDecoder(r) 607 608 // Decode the action 609 var a gitbe.JournalAction 610 err := d.Decode(&a) 611 if err != nil { 612 return nil, err 613 } 614 615 // Decode the journal entry 616 switch a.Action { 617 case gitbe.JournalActionAdd: 618 var cm gitbe.Comment 619 err = d.Decode(&cm) 620 if err != nil { 621 return nil, err 622 } 623 userID, err := c.userIDByPubKey(cm.PublicKey) 624 if err != nil { 625 return nil, err 626 } 627 ca := convertCommentAdd(cm, userID) 628 adds[cm.CommentID] = ca 629 630 // Save the parent ID 631 parentIDs[cm.CommentID] = ca.ParentID 632 633 case gitbe.JournalActionDel: 634 var cc gitbe.CensorComment 635 err = d.Decode(&cc) 636 if err != nil { 637 return nil, err 638 } 639 userID, err := c.userIDByPubKey(cc.PublicKey) 640 if err != nil { 641 return nil, err 642 } 643 parentID, ok := parentIDs[cc.CommentID] 644 if !ok { 645 return nil, fmt.Errorf("parent id not found for %v", cc.CommentID) 646 } 647 dels[cc.CommentID] = convertCommentDel(cc, parentID, userID) 648 649 case gitbe.JournalActionAddLike: 650 var lc gitbe.LikeComment 651 err = d.Decode(&lc) 652 if err != nil { 653 return nil, err 654 } 655 userID, err := c.userIDByPubKey(lc.PublicKey) 656 if err != nil { 657 return nil, err 658 } 659 votes[lc.Signature] = convertCommentVote(lc, userID) 660 661 default: 662 return nil, fmt.Errorf("invalid action '%v'", a.Action) 663 } 664 } 665 err = scanner.Err() 666 if err != nil { 667 return nil, err 668 } 669 670 fmt.Printf(" Parsed %v comment adds\n", len(adds)) 671 fmt.Printf(" Parsed %v comment dels\n", len(dels)) 672 fmt.Printf(" Parsed %v comment votes\n", len(votes)) 673 674 // Convert the maps into slices and sort them by timestamp 675 // from oldest to newest. 676 var ( 677 sortedAdds = make([]comments.CommentAdd, 0, len(adds)) 678 sortedDels = make([]comments.CommentDel, 0, len(dels)) 679 sortedVotes = make([]comments.CommentVote, 0, len(votes)) 680 ) 681 for _, v := range adds { 682 sortedAdds = append(sortedAdds, v) 683 } 684 for _, v := range dels { 685 sortedDels = append(sortedDels, v) 686 } 687 for _, v := range votes { 688 sortedVotes = append(sortedVotes, v) 689 } 690 sort.SliceStable(sortedAdds, func(i, j int) bool { 691 return sortedAdds[i].Timestamp < sortedAdds[j].Timestamp 692 }) 693 sort.SliceStable(sortedDels, func(i, j int) bool { 694 return sortedDels[i].Timestamp < sortedDels[j].Timestamp 695 }) 696 sort.SliceStable(sortedVotes, func(i, j int) bool { 697 return sortedVotes[i].Timestamp < sortedVotes[j].Timestamp 698 }) 699 700 return &commentTypes{ 701 Adds: sortedAdds, 702 Dels: sortedDels, 703 Votes: sortedVotes, 704 }, nil 705 } 706 707 // convertAuthDetails reads the git backend data from disk that is required to 708 // build a ticketvote plugin AuthDetails structure, then returns the 709 // AuthDetails. 710 func (c *convertCmd) convertAuthDetails(proposalDir string) (*ticketvote.AuthDetails, error) { 711 fmt.Printf(" AuthDetails\n") 712 713 // Verify that an authorize vote mdstream exists. 714 // This will not exist for some proposals, e.g. 715 // abandoned proposals. 716 fp := authorizeVotePath(proposalDir) 717 if _, err := os.Stat(fp); err != nil { 718 switch { 719 case errors.Is(err, os.ErrNotExist): 720 // File does not exist 721 return nil, nil 722 723 default: 724 // Unknown error 725 return nil, err 726 } 727 } 728 729 // Read the authorize vote mdstream from disk 730 b, err := os.ReadFile(fp) 731 if err != nil { 732 return nil, err 733 } 734 var av gitbe.AuthorizeVote 735 err = json.Unmarshal(b, &av) 736 if err != nil { 737 return nil, err 738 } 739 740 // Parse the token and version from the proposal dir path 741 token, ok := parseProposalToken(proposalDir) 742 if !ok { 743 return nil, fmt.Errorf("token not found in path '%v'", proposalDir) 744 } 745 if av.Token != token { 746 return nil, fmt.Errorf("auth vote token invalid: got %v, want %v", 747 av.Token, token) 748 } 749 version, err := parseProposalVersion(proposalDir) 750 if err != nil { 751 return nil, err 752 } 753 754 // Build the ticketvote AuthDetails 755 ad := ticketvote.AuthDetails{ 756 Token: av.Token, 757 Version: version, 758 Action: av.Action, 759 PublicKey: av.PublicKey, 760 Signature: av.Signature, 761 Timestamp: av.Timestamp, 762 Receipt: av.Receipt, 763 } 764 765 // Verify signatures 766 adv1 := convertAuthDetailsToV1(ad) 767 err = client.AuthDetailsVerify(adv1, gitbe.PublicKey) 768 if err != nil { 769 return nil, err 770 } 771 772 fmt.Printf(" Token : %v\n", ad.Token) 773 fmt.Printf(" Version : %v\n", ad.Version) 774 fmt.Printf(" Action : %v\n", ad.Action) 775 fmt.Printf(" PublicKey: %v\n", ad.PublicKey) 776 fmt.Printf(" Signature: %v\n", ad.Signature) 777 fmt.Printf(" Timestamp: %v\n", ad.Timestamp) 778 fmt.Printf(" Receipt : %v\n", ad.Receipt) 779 780 return &ad, nil 781 } 782 783 // convertVoteDetails reads the git backend data from disk that is required to 784 // build a ticketvote plugin VoteDetails structure, then returns the 785 // VoteDetails. 786 func (c *convertCmd) convertVoteDetails(proposalDir string, voteMD *ticketvote.VoteMetadata) (*ticketvote.VoteDetails, error) { 787 fmt.Printf(" Vote details\n") 788 789 // Verify that vote mdstreams exists. These 790 // will not exist for some proposals, such 791 // as abandoned proposals. 792 fp := startVotePath(proposalDir) 793 if _, err := os.Stat(fp); err != nil { 794 switch { 795 case errors.Is(err, os.ErrNotExist): 796 // File does not exist. No need to continue. 797 return nil, nil 798 799 default: 800 // Unknown error 801 return nil, err 802 } 803 } 804 805 // Read the start vote from disk 806 startVoteJSON, err := os.ReadFile(fp) 807 if err != nil { 808 return nil, err 809 } 810 811 // Read the start vote reply from disk 812 fp = startVoteReplyPath(proposalDir) 813 b, err := os.ReadFile(fp) 814 if err != nil { 815 return nil, err 816 } 817 var svr gitbe.StartVoteReply 818 err = json.Unmarshal(b, &svr) 819 if err != nil { 820 return nil, err 821 } 822 823 // Pull the proposal version from the proposal dir path 824 version, err := parseProposalVersion(proposalDir) 825 if err != nil { 826 return nil, err 827 } 828 829 // Build the vote details 830 vd := convertVoteDetails(startVoteJSON, svr, version, voteMD) 831 832 fmt.Printf(" Token : %v\n", vd.Params.Token) 833 fmt.Printf(" Version : %v\n", vd.Params.Version) 834 fmt.Printf(" Type : %v\n", vd.Params.Type) 835 fmt.Printf(" Mask : %v\n", vd.Params.Mask) 836 fmt.Printf(" Duration : %v\n", vd.Params.Duration) 837 fmt.Printf(" Quorum : %v\n", vd.Params.QuorumPercentage) 838 fmt.Printf(" Pass : %v\n", vd.Params.PassPercentage) 839 fmt.Printf(" Options : %+v\n", vd.Params.Options) 840 fmt.Printf(" Parent : %v\n", vd.Params.Parent) 841 fmt.Printf(" Start height: %v\n", vd.StartBlockHeight) 842 fmt.Printf(" Start hash : %v\n", vd.StartBlockHash) 843 fmt.Printf(" End height : %v\n", vd.EndBlockHeight) 844 845 return &vd, nil 846 } 847 848 // convertCastVotes reads the git backend data from disk that is required to 849 // build the ticketvote plugin CastVoteDetails structures, then returns the 850 // CastVoteDetails slice. 851 // 852 // This process includes parsing the ballot journal from the git repo, 853 // retrieving the commitment addresses from dcrdata for each vote, and parsing 854 // the git commit log to associate each vote with a commit timestamp. 855 func (c *convertCmd) convertCastVotes(proposalDir string) ([]ticketvote.CastVoteDetails, error) { 856 fmt.Printf(" Cast votes\n") 857 858 // Verify that the ballots journal exists. This 859 /// will not exist for some proposals, such as 860 // abandoned proposals. 861 fp := ballotsJournalPath(proposalDir) 862 if _, err := os.Stat(fp); err != nil { 863 switch { 864 case errors.Is(err, os.ErrNotExist): 865 // File does not exist 866 return nil, nil 867 868 default: 869 // Unknown error 870 return nil, err 871 } 872 } 873 874 // Open the ballots journal 875 f, err := os.Open(fp) 876 if err != nil { 877 return nil, err 878 } 879 defer f.Close() 880 881 // Read the journal line-by-line 882 var ( 883 scanner = bufio.NewScanner(f) 884 885 // There are some duplicate votes in early proposals due to 886 // a bug. Use a map here so that duplicate votes are removed. 887 // 888 // map[ticket]CastVoteDetails 889 votes = make(map[string]gitbe.CastVoteJournal, 40960) 890 891 // Ticket hashes of all cast votes. These are used to 892 // fetch the largest commitment address for each ticket. 893 tickets = make([]string, 0, 40960) 894 ) 895 for scanner.Scan() { 896 // Decode the current line 897 r := bytes.NewReader(scanner.Bytes()) 898 d := json.NewDecoder(r) 899 900 var j gitbe.JournalAction 901 err := d.Decode(&j) 902 if err != nil { 903 return nil, err 904 } 905 if j.Action != gitbe.JournalActionAdd { 906 return nil, fmt.Errorf("invalid action '%v'", j.Action) 907 } 908 909 var cvj gitbe.CastVoteJournal 910 err = d.Decode(&cvj) 911 if err != nil { 912 return nil, err 913 } 914 915 // Save the cast vote 916 votes[cvj.CastVote.Ticket] = cvj 917 tickets = append(tickets, cvj.CastVote.Ticket) 918 } 919 err = scanner.Err() 920 if err != nil { 921 return nil, err 922 } 923 924 fmt.Printf(" Parsed %v vote journal entries\n", len(votes)) 925 926 // Fetch largest commitment address for each vote 927 caddrs, err := c.commitmentAddrs(tickets) 928 if err != nil { 929 return nil, err 930 } 931 932 // Parse the vote timestamps. These are not the timestamps 933 // of when the vote was actually cast, but rather the 934 // timestamp of when the vote was committed to the git 935 // repo. This is the most accurate timestamp that we have. 936 voteTS, err := parseVoteTimestamps(proposalDir) 937 if err != nil { 938 return nil, err 939 } 940 941 // Convert the votes 942 castVotes := make([]ticketvote.CastVoteDetails, 0, len(votes)) 943 for ticket, vote := range votes { 944 caddr, ok := caddrs[ticket] 945 if !ok { 946 return nil, fmt.Errorf("commitment address not found for %v", ticket) 947 } 948 ts, ok := voteTS[ticket] 949 if !ok { 950 return nil, fmt.Errorf("timestamp not found for vote %v", ticket) 951 } 952 cv := convertCastVoteDetails(vote, caddr, ts) 953 castVotes = append(castVotes, cv) 954 } 955 956 // Sort the votes from oldest to newest 957 sort.SliceStable(castVotes, func(i, j int) bool { 958 return castVotes[i].Timestamp < castVotes[j].Timestamp 959 }) 960 961 // Tally votes and print the vote statistics 962 results := make(map[string]int) 963 for _, v := range castVotes { 964 results[v.VoteBit]++ 965 } 966 var total int 967 for voteBit, voteCount := range results { 968 fmt.Printf(" %v : %v\n", voteBit, voteCount) 969 total += voteCount 970 } 971 fmt.Printf(" Total: %v\n", total) 972 973 // Verify all cast vote signatures 974 for i, v := range castVotes { 975 s := fmt.Sprintf(" Verifying cast vote signature %v/%v", 976 i+1, len(votes)) 977 printInPlace(s) 978 979 voteV1 := convertCastVoteDetailsToV1(v) 980 err = client.CastVoteDetailsVerify(voteV1, gitbe.PublicKey) 981 if err != nil { 982 return nil, err 983 } 984 } 985 fmt.Printf("\n") 986 987 return castVotes, nil 988 } 989 990 // userIDByPubKey retrieves and returns the user ID from the politeia API for 991 // the provided public key. The results are cached in memory. 992 func (c *convertCmd) userIDByPubKey(userPubKey string) (string, error) { 993 userID := c.getUserIDByPubKey(userPubKey) 994 if userID != "" { 995 return userID, nil 996 } 997 u, err := userByPubKey(c.client, userPubKey) 998 if err != nil { 999 return "", err 1000 } 1001 if u.ID == "" { 1002 return "", fmt.Errorf("user id not found") 1003 } 1004 c.setUserIDByPubKey(userPubKey, u.ID) 1005 return u.ID, nil 1006 } 1007 1008 func (c *convertCmd) setUserIDByPubKey(pubKey, userID string) { 1009 c.Lock() 1010 defer c.Unlock() 1011 1012 c.userIDs[pubKey] = userID 1013 } 1014 1015 func (c *convertCmd) getUserIDByPubKey(pubKey string) string { 1016 c.Lock() 1017 defer c.Unlock() 1018 1019 return c.userIDs[pubKey] 1020 } 1021 1022 // parseProposalName parses and returns the proposal name from the proposal 1023 // index file. 1024 func parseProposalName(proposalDir string) (string, error) { 1025 // Read the index file from disk 1026 fp := indexFilePath(proposalDir) 1027 b, err := os.ReadFile(fp) 1028 if err != nil { 1029 return "", err 1030 } 1031 1032 // Parse the proposal name from the index file. The 1033 // proposal name will always be the first line of the 1034 // file. 1035 r := bufio.NewReader(bytes.NewReader(b)) 1036 name, _, err := r.ReadLine() 1037 if err != nil { 1038 return "", err 1039 } 1040 1041 return string(name), nil 1042 } 1043 1044 // convertCastVoteDetailsToV1 converts a cast vote details from the plugin type 1045 // to the API type so that we can use the API provided method to verify the 1046 // signature. The data structures are exactly the same. 1047 func convertCastVoteDetailsToV1(vote ticketvote.CastVoteDetails) v1.CastVoteDetails { 1048 return v1.CastVoteDetails{ 1049 Token: vote.Token, 1050 Ticket: vote.Ticket, 1051 VoteBit: vote.VoteBit, 1052 Address: vote.Address, 1053 Signature: vote.Signature, 1054 Receipt: vote.Receipt, 1055 Timestamp: vote.Timestamp, 1056 } 1057 } 1058 1059 // convertAuthDetailsToV1 converts a auth details from the plugin type to the 1060 // API type so that we can use the API provided methods to verify the 1061 // signature. The data structures are exactly the same. 1062 func convertAuthDetailsToV1(a ticketvote.AuthDetails) v1.AuthDetails { 1063 return v1.AuthDetails{ 1064 Token: a.Token, 1065 Version: a.Version, 1066 Action: a.Action, 1067 PublicKey: a.PublicKey, 1068 Signature: a.Signature, 1069 Timestamp: a.Timestamp, 1070 Receipt: a.Receipt, 1071 } 1072 }