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  }