github.com/decred/politeia@v1.4.0/politeiad/backendv2/tstorebe/plugins/pi/hooks.go (about) 1 // Copyright (c) 2020-2022 The Decred developers 2 // Use of this source code is governed by an ISC 3 // license that can be found in the LICENSE file. 4 5 package pi 6 7 import ( 8 "encoding/base64" 9 "encoding/json" 10 "fmt" 11 "strings" 12 "time" 13 14 backend "github.com/decred/politeia/politeiad/backendv2" 15 "github.com/decred/politeia/politeiad/backendv2/tstorebe/plugins" 16 "github.com/decred/politeia/politeiad/plugins/comments" 17 "github.com/decred/politeia/politeiad/plugins/pi" 18 "github.com/decred/politeia/politeiad/plugins/ticketvote" 19 "github.com/decred/politeia/politeiad/plugins/usermd" 20 "github.com/decred/politeia/util" 21 "github.com/pkg/errors" 22 ) 23 24 const ( 25 // Accepted MIME types 26 mimeTypeText = "text/plain" 27 mimeTypeTextUTF8 = "text/plain; charset=utf-8" 28 mimeTypePNG = "image/png" 29 ) 30 31 var ( 32 // allowedTextFiles contains the filenames of the only text files 33 // that are allowed to be submitted as part of a proposal. 34 allowedTextFiles = map[string]struct{}{ 35 pi.FileNameIndexFile: {}, 36 pi.FileNameProposalMetadata: {}, 37 ticketvote.FileNameVoteMetadata: {}, 38 } 39 ) 40 41 // hookNewRecordPre adds plugin specific validation onto the tstore backend 42 // RecordNew method. 43 func (p *piPlugin) hookNewRecordPre(payload string) error { 44 var nr plugins.HookNewRecordPre 45 err := json.Unmarshal([]byte(payload), &nr) 46 if err != nil { 47 return err 48 } 49 50 return p.proposalFilesVerify(nr.Files) 51 } 52 53 // hookEditRecordPre adds plugin specific validation onto the tstore backend 54 // RecordEdit method. 55 func (p *piPlugin) hookEditRecordPre(payload string) error { 56 var er plugins.HookEditRecord 57 err := json.Unmarshal([]byte(payload), &er) 58 if err != nil { 59 return err 60 } 61 62 // Verify proposal files 63 err = p.proposalFilesVerify(er.Files) 64 if err != nil { 65 return err 66 } 67 68 // Verify vote status. Edits are not allowed to be made once a vote 69 // has been authorized. This only needs to be checked for vetted 70 // records since you cannot authorize or start a ticket vote on an 71 // unvetted record. 72 if er.RecordMetadata.State == backend.StateVetted { 73 t, err := tokenDecode(er.RecordMetadata.Token) 74 if err != nil { 75 return err 76 } 77 s, err := p.voteSummary(t) 78 if err != nil { 79 return err 80 } 81 if s.Status != ticketvote.VoteStatusUnauthorized { 82 return backend.PluginError{ 83 PluginID: pi.PluginID, 84 ErrorCode: uint32(pi.ErrorCodeVoteStatusInvalid), 85 ErrorContext: fmt.Sprintf("vote status '%v' "+ 86 "does not allow for proposal edits", 87 ticketvote.VoteStatuses[s.Status]), 88 } 89 } 90 } 91 92 return nil 93 } 94 95 // hookCommentNew adds pi specific validation onto the comments plugin New 96 // command. 97 func (p *piPlugin) hookCommentNew(token []byte, cmd, payload string) error { 98 return p.commentWritesAllowed(token, cmd, payload) 99 } 100 101 // hookCommentDel adds pi specific validation onto the comments plugin Del 102 // command. 103 func (p *piPlugin) hookCommentDel(token []byte, cmd, payload string) error { 104 return p.commentWritesAllowed(token, cmd, payload) 105 } 106 107 // hookCommentVote adds pi specific validation onto the comments plugin Vote 108 // command. 109 func (p *piPlugin) hookCommentVote(token []byte, cmd, payload string) error { 110 return p.commentWritesAllowed(token, cmd, payload) 111 } 112 113 // hookPluginPre extends plugin write commands from other plugins with pi 114 // specific validation. 115 func (p *piPlugin) hookPluginPre(payload string) error { 116 // Decode payload 117 var hpp plugins.HookPluginPre 118 err := json.Unmarshal([]byte(payload), &hpp) 119 if err != nil { 120 return err 121 } 122 123 // Call plugin hook 124 switch hpp.PluginID { 125 case comments.PluginID: 126 switch hpp.Cmd { 127 case comments.CmdNew: 128 return p.hookCommentNew(hpp.Token, hpp.Cmd, hpp.Payload) 129 case comments.CmdDel: 130 return p.hookCommentDel(hpp.Token, hpp.Cmd, hpp.Payload) 131 case comments.CmdVote: 132 return p.hookCommentVote(hpp.Token, hpp.Cmd, hpp.Payload) 133 } 134 } 135 136 return nil 137 } 138 139 // titleIsValid returns whether the provided title, which can be either a 140 // proposal name or an author update title, matches the pi plugin title regex. 141 func (p *piPlugin) titleIsValid(title string) bool { 142 return p.titleRegexp.MatchString(title) 143 } 144 145 // proposalStartDateIsValid returns whether the provided start date is valid. 146 // 147 // A valid start date of a proposal must be after the minimum start date 148 // set by the proposalStartDateMin plugin setting. 149 func (p *piPlugin) proposalStartDateIsValid(start int64) bool { 150 return start > time.Now().Unix()+p.proposalStartDateMin 151 } 152 153 // proposalEndDateIsValid returns whether the provided end date is valid. 154 // 155 // A valid end date must be after the start date and before the end of the 156 // time interval set by the proposalEndDateMax plugin setting. 157 func (p *piPlugin) proposalEndDateIsValid(start int64, end int64) bool { 158 return end > start && 159 time.Now().Unix()+p.proposalEndDateMax > end 160 } 161 162 // proposalAmountIsValid returns whether the provided amount is in the range 163 // defined by the proposalAmountMin & proposalAmountMax plugin settings. 164 func (p *piPlugin) proposalAmountIsValid(amount uint64) bool { 165 return p.proposalAmountMin <= amount && 166 p.proposalAmountMax >= amount 167 } 168 169 // proposalDomainIsValid returns whether the provided domain is 170 // is a valid proposal domain. 171 func (p *piPlugin) proposalDomainIsValid(domain string) bool { 172 _, found := p.proposalDomains[domain] 173 return found 174 } 175 176 // isRFP returns true if the given vote metadata contains the metadata for 177 // an RFP. 178 func isRFP(vm *ticketvote.VoteMetadata) bool { 179 return vm != nil && vm.LinkBy != 0 180 } 181 182 // proposalFilesVerify verifies the files adhere to all pi plugin setting 183 // requirements. If this hook is being executed then the files have already 184 // passed politeiad validation so we can assume that the file has a unique 185 // name, a valid base64 payload, and that the file digest and MIME type are 186 // correct. 187 func (p *piPlugin) proposalFilesVerify(files []backend.File) error { 188 // Sanity check 189 if len(files) == 0 { 190 return errors.Errorf("no files found") 191 } 192 193 // Verify file types and sizes 194 var imagesCount uint32 195 for _, v := range files { 196 payload, err := base64.StdEncoding.DecodeString(v.Payload) 197 if err != nil { 198 return errors.Errorf("invalid base64 %v", v.Name) 199 } 200 201 // MIME type specific validation 202 switch v.MIME { 203 case mimeTypeText, mimeTypeTextUTF8: 204 // Verify text file is allowed 205 _, ok := allowedTextFiles[v.Name] 206 if !ok { 207 allowed := make([]string, 0, len(allowedTextFiles)) 208 for name := range allowedTextFiles { 209 allowed = append(allowed, name) 210 } 211 return backend.PluginError{ 212 PluginID: pi.PluginID, 213 ErrorCode: uint32(pi.ErrorCodeTextFileNameInvalid), 214 ErrorContext: fmt.Sprintf("invalid text file name "+ 215 "%v; allowed text file names are %v", 216 v.Name, strings.Join(allowed, ", ")), 217 } 218 } 219 220 // Verify text file size 221 if len(payload) > int(p.textFileSizeMax) { 222 return backend.PluginError{ 223 PluginID: pi.PluginID, 224 ErrorCode: uint32(pi.ErrorCodeTextFileSizeInvalid), 225 ErrorContext: fmt.Sprintf("file %v "+ 226 "size %v exceeds max size %v", 227 v.Name, len(payload), 228 p.textFileSizeMax), 229 } 230 } 231 232 case mimeTypePNG: 233 imagesCount++ 234 235 // Verify image file size 236 if len(payload) > int(p.imageFileSizeMax) { 237 return backend.PluginError{ 238 PluginID: pi.PluginID, 239 ErrorCode: uint32(pi.ErrorCodeImageFileSizeInvalid), 240 ErrorContext: fmt.Sprintf("image %v "+ 241 "size %v exceeds max size %v", 242 v.Name, len(payload), 243 p.imageFileSizeMax), 244 } 245 } 246 247 default: 248 return errors.Errorf("invalid mime: %v", v.MIME) 249 } 250 } 251 252 // Verify that an index file is present 253 var found bool 254 for _, v := range files { 255 if v.Name == pi.FileNameIndexFile { 256 found = true 257 break 258 } 259 } 260 if !found { 261 return backend.PluginError{ 262 PluginID: pi.PluginID, 263 ErrorCode: uint32(pi.ErrorCodeTextFileMissing), 264 ErrorContext: pi.FileNameIndexFile, 265 } 266 } 267 268 // Verify image file count is acceptable 269 if imagesCount > p.imageFileCountMax { 270 return backend.PluginError{ 271 PluginID: pi.PluginID, 272 ErrorCode: uint32(pi.ErrorCodeImageFileCountInvalid), 273 ErrorContext: fmt.Sprintf("got %v image files, max "+ 274 "is %v", imagesCount, p.imageFileCountMax), 275 } 276 } 277 278 // Verify a proposal metadata has been included 279 pm, err := proposalMetadataDecode(files) 280 if err != nil { 281 return err 282 } 283 if pm == nil { 284 return backend.PluginError{ 285 PluginID: pi.PluginID, 286 ErrorCode: uint32(pi.ErrorCodeTextFileMissing), 287 ErrorContext: pi.FileNameProposalMetadata, 288 } 289 } 290 291 // Validate vote & proposal metadata requirements 292 vm, err := voteMetadataDecode(files) 293 if err != nil { 294 return err 295 } 296 // In case of an RFP ensure irrelevant proposal metadata are not provided. 297 if isRFP(vm) { 298 switch { 299 case pm.Amount != 0: 300 return backend.PluginError{ 301 PluginID: pi.PluginID, 302 ErrorCode: uint32(pi.ErrorCodeProposalAmountInvalid), 303 ErrorContext: "RFP metadata should not include an amount", 304 } 305 case pm.StartDate != 0: 306 return backend.PluginError{ 307 PluginID: pi.PluginID, 308 ErrorCode: uint32(pi.ErrorCodeProposalStartDateInvalid), 309 ErrorContext: "RFP metadata should not include a start date", 310 } 311 case pm.EndDate != 0: 312 return backend.PluginError{ 313 PluginID: pi.PluginID, 314 ErrorCode: uint32(pi.ErrorCodeProposalEndDateInvalid), 315 ErrorContext: "RFP metadata should not include an end date", 316 } 317 } 318 } 319 320 // Verify proposal name 321 if !p.titleIsValid(pm.Name) { 322 return backend.PluginError{ 323 PluginID: pi.PluginID, 324 ErrorCode: uint32(pi.ErrorCodeTitleInvalid), 325 ErrorContext: p.titleRegexp.String(), 326 } 327 } 328 329 // Validate proposal domain. 330 if !p.proposalDomainIsValid(pm.Domain) { 331 return backend.PluginError{ 332 PluginID: pi.PluginID, 333 ErrorCode: uint32(pi.ErrorCodeProposalDomainInvalid), 334 ErrorContext: fmt.Sprintf("got %v domain, "+ 335 "supported domains are: %v", pm.Domain, p.proposalDomains), 336 } 337 } 338 339 // Ensure legacy token is not set during normal proposal submissions 340 if pm.LegacyToken != "" { 341 return backend.PluginError{ 342 PluginID: pi.PluginID, 343 ErrorCode: uint32(pi.ErrorCodeLegacyTokenNotAllowed), 344 } 345 } 346 347 // If not RFP validate rest of proposal metadata fields 348 if !isRFP(vm) { 349 // Validate proposal start date. 350 if !p.proposalStartDateIsValid(pm.StartDate) { 351 return backend.PluginError{ 352 PluginID: pi.PluginID, 353 ErrorCode: uint32(pi.ErrorCodeProposalStartDateInvalid), 354 ErrorContext: fmt.Sprintf("start date (%v) must be after %v", 355 pm.StartDate, time.Now().Unix()-p.proposalStartDateMin), 356 } 357 } 358 359 // Validate proposal end date. 360 if !p.proposalEndDateIsValid(pm.StartDate, pm.EndDate) { 361 return backend.PluginError{ 362 PluginID: pi.PluginID, 363 ErrorCode: uint32(pi.ErrorCodeProposalEndDateInvalid), 364 ErrorContext: fmt.Sprintf("end date (%v) must be before %v", 365 pm.EndDate, time.Now().Unix()+p.proposalEndDateMax), 366 } 367 } 368 369 // Validate proposal amount. 370 if !p.proposalAmountIsValid(pm.Amount) { 371 return backend.PluginError{ 372 PluginID: pi.PluginID, 373 ErrorCode: uint32(pi.ErrorCodeProposalAmountInvalid), 374 ErrorContext: fmt.Sprintf("got %v amount, min is %v, "+ 375 "max is %v", pm.Amount, p.proposalAmountMin, p.proposalAmountMax), 376 } 377 } 378 } 379 380 return nil 381 } 382 383 // voteSummary requests the vote summary from the ticketvote plugin for a 384 // record. 385 func (p *piPlugin) voteSummary(token []byte) (*ticketvote.SummaryReply, error) { 386 reply, err := p.backend.PluginRead(token, ticketvote.PluginID, 387 ticketvote.CmdSummary, "") 388 if err != nil { 389 return nil, err 390 } 391 var sr ticketvote.SummaryReply 392 err = json.Unmarshal([]byte(reply), &sr) 393 if err != nil { 394 return nil, err 395 } 396 return &sr, nil 397 } 398 399 // comments requests all comments on a record from the comments plugin. 400 func (p *piPlugin) comments(token []byte) (*comments.GetAllReply, error) { 401 reply, err := p.backend.PluginRead(token, comments.PluginID, 402 comments.CmdGetAll, "") 403 if err != nil { 404 return nil, err 405 } 406 var gar comments.GetAllReply 407 err = json.Unmarshal([]byte(reply), &gar) 408 if err != nil { 409 return nil, err 410 } 411 return &gar, nil 412 } 413 414 // isInCommentTree returns whether the leafID is part of the provided comment 415 // tree. A leaf is considered to be part of the tree if the leaf is a child of 416 // the root or the leaf references the root itself. 417 func isInCommentTree(rootID, leafID uint32, cs []comments.Comment) bool { 418 if leafID == rootID { 419 return true 420 } 421 // Convert comments slice to a map 422 commentsMap := make(map[uint32]comments.Comment, len(cs)) 423 for _, c := range cs { 424 commentsMap[c.CommentID] = c 425 } 426 427 // Start with the provided comment leaf and traverse the comment tree up 428 // until either the provided root ID is found or we reach the tree head. The 429 // tree head will have a comment ID of 0. 430 current := commentsMap[leafID] 431 for current.ParentID != 0 { 432 // Check if next parent in the tree is the rootID. 433 if current.ParentID == rootID { 434 return true 435 } 436 leafID = current.ParentID 437 current = commentsMap[leafID] 438 } 439 return false 440 } 441 442 // latestAuthorUpdate gets the latest author update on a record, if 443 // the record has no author update it returns nil. 444 func latestAuthorUpdate(token []byte, cs []comments.Comment) *comments.Comment { 445 var latestAuthorUpdate comments.Comment 446 for _, c := range cs { 447 if c.ExtraDataHint != pi.ProposalUpdateHint { 448 continue 449 } 450 if c.Timestamp > latestAuthorUpdate.Timestamp { 451 latestAuthorUpdate = c 452 } 453 } 454 return &latestAuthorUpdate 455 } 456 457 // recordAuthor returns the author's userID of the record associated with 458 // the provided token. 459 func (p *piPlugin) recordAuthor(token []byte) (string, error) { 460 reply, err := p.backend.PluginRead(token, usermd.PluginID, 461 usermd.CmdAuthor, "") 462 if err != nil { 463 return "", err 464 } 465 var ar usermd.AuthorReply 466 err = json.Unmarshal([]byte(reply), &ar) 467 if err != nil { 468 return "", err 469 } 470 return ar.UserID, nil 471 } 472 473 // commentVoteAllowedOnApprovedProposal verifies that the given comment 474 // vote is allowed on a proposal which finished voting and it's vote was 475 // approved. 476 func (p *piPlugin) commentVoteAllowedOnApprovedProposal(token []byte, payload string, latestAuthorUpdate comments.Comment, cs []comments.Comment) error { 477 // Decode payload 478 var v comments.Vote 479 err := json.Unmarshal([]byte(payload), &v) 480 if err != nil { 481 return err 482 } 483 484 if !isInCommentTree(latestAuthorUpdate.CommentID, v.CommentID, cs) { 485 return backend.PluginError{ 486 PluginID: pi.PluginID, 487 ErrorCode: uint32(pi.ErrorCodeCommentWriteNotAllowed), 488 ErrorContext: "votes are only allowed on the author's " + 489 "most recent update thread", 490 } 491 } 492 493 return nil 494 } 495 496 // isValidAuthorUpdate returns whether the given new comment is a valid author 497 // update. 498 // 499 // The comment must include proper proposal update metadata and the comment 500 // must be submitted by the proposal author for it to be considered a valid 501 // author update. 502 func (p *piPlugin) isValidAuthorUpdate(token []byte, n comments.New) error { 503 // Get the proposal author. The proposal author 504 // and the comment author must be the same user. 505 recordAuthorID, err := p.recordAuthor(token) 506 if err != nil { 507 return err 508 } 509 if recordAuthorID != n.UserID { 510 return backend.PluginError{ 511 PluginID: pi.PluginID, 512 ErrorCode: uint32(pi.ErrorCodeCommentWriteNotAllowed), 513 ErrorContext: "user is not the proposal author", 514 } 515 } 516 517 // Verify extra data fields 518 if n.ExtraDataHint != pi.ProposalUpdateHint { 519 return backend.PluginError{ 520 PluginID: pi.PluginID, 521 ErrorCode: uint32(pi.ErrorCodeExtraDataHintInvalid), 522 ErrorContext: fmt.Sprintf("got %v, want %v", 523 n.ExtraDataHint, pi.ProposalUpdateHint), 524 } 525 } 526 var pum pi.ProposalUpdateMetadata 527 err = json.Unmarshal([]byte(n.ExtraData), &pum) 528 if err != nil { 529 return backend.PluginError{ 530 PluginID: pi.PluginID, 531 ErrorCode: uint32(pi.ErrorCodeExtraDataInvalid), 532 } 533 } 534 535 // Verify update title 536 if !p.titleIsValid(pum.Title) { 537 return backend.PluginError{ 538 PluginID: pi.PluginID, 539 ErrorCode: uint32(pi.ErrorCodeTitleInvalid), 540 ErrorContext: p.titleRegexp.String(), 541 } 542 } 543 544 // The comment is a valid author update. 545 return nil 546 } 547 548 // commentNewAllowedOnApprovedProposal verifies that the given new comment 549 // is allowed on a proposal which finished voting and it's vote was approved. 550 func (p *piPlugin) commentNewAllowedOnApprovedProposal(token []byte, payload string, latestAuthorUpdate comments.Comment, cs []comments.Comment) error { 551 // Decode payload 552 var n comments.New 553 err := json.Unmarshal([]byte(payload), &n) 554 if err != nil { 555 return err 556 } 557 558 // A new comment on an approved proposal must either be an update 559 // from the author (parent ID will be 0) or a reply to the latest 560 // author update. 561 isUpdateReply := isInCommentTree(latestAuthorUpdate.CommentID, 562 n.ParentID, cs) 563 switch { 564 case n.ParentID == 0: 565 // This might be an update from the author. 566 return p.isValidAuthorUpdate(token, n) 567 568 case isUpdateReply: 569 // This is a reply to the latest update. This is allowed. 570 return nil 571 572 case !isUpdateReply: 573 // New comment is a reply, but is not a reply to the latest update. This 574 // is not allowed. 575 return backend.PluginError{ 576 PluginID: pi.PluginID, 577 ErrorCode: uint32(pi.ErrorCodeCommentWriteNotAllowed), 578 ErrorContext: "comment replies are only allowed on " + 579 "the author's most recent update thread", 580 } 581 582 default: 583 // This should not happen 584 return errors.Errorf("unknown comment write state") 585 } 586 } 587 588 // writesAllowedOnApprovedProposal verifies that the given comment write is 589 // allowed on a proposal which finished voting and it's vote was approved. This 590 // includes both comments and comment votes. 591 func (p *piPlugin) writesAllowedOnApprovedProposal(token []byte, cmd, payload string) error { 592 // Get billing status to determine whether to allow author updates 593 // or not. 594 var bsc *pi.BillingStatusChange 595 bscs, err := p.billingStatusChanges(token) 596 if err != nil { 597 return err 598 } 599 if len(bscs) > 0 { 600 // Get latest billing status change 601 bsc = &bscs[len(bscs)-1] 602 if bsc.Status == pi.BillingStatusClosed || 603 bsc.Status == pi.BillingStatusCompleted { 604 // If billing status is set to closed or completed, comment writes 605 // are not allowed. 606 return backend.PluginError{ 607 PluginID: pi.PluginID, 608 ErrorCode: uint32(pi.ErrorCodeBillingStatusInvalid), 609 ErrorContext: "billing status is set to closed/completed;" + 610 " proposal is locked", 611 } 612 } 613 } 614 615 // Get latest proposal author update 616 gar, err := p.comments(token) 617 if err != nil { 618 return err 619 } 620 latestAuthorUpdate := latestAuthorUpdate(token, gar.Comments) 621 622 switch cmd { 623 // If the user is submitting a new comment then it must be either a new 624 // author update or a comment on the latest author update thread. 625 case comments.CmdNew: 626 return p.commentNewAllowedOnApprovedProposal(token, payload, 627 *latestAuthorUpdate, gar.Comments) 628 629 // If the user is voting on a comment then it must be on one of the latest 630 // author update thread comments. 631 case comments.CmdVote: 632 return p.commentVoteAllowedOnApprovedProposal(token, payload, 633 *latestAuthorUpdate, gar.Comments) 634 635 } 636 637 return nil 638 } 639 640 // commentWritesAllowed verifies that a proposal has a vote status that allows 641 // comment writes to be made to the proposal. This includes both comments and 642 // comment votes. 643 // 644 // Once a proposal vote has finished, all existing comment threads are locked. 645 // 646 // When a proposal author wants to give an update on their **approved** 647 // proposal they can start a new comment thread. 648 // 649 // The author is the only user that will have the ability to 650 // start a new comment thread once the voting period has finished. 651 // 652 // Each update must have an author provided title. 653 // 654 // Anyone can reply to any comments in the thread and can cast 655 // upvotes/downvotes for any comments in the thread. 656 // 657 // The comment thread will remain open until either the author starts a new 658 // update thread or an admin marks the proposal as closed/completed. 659 func (p *piPlugin) commentWritesAllowed(token []byte, cmd, payload string) error { 660 // Get record state 661 r, err := p.recordAbridged(token) 662 if err != nil { 663 return err 664 } 665 state := r.RecordMetadata.State 666 667 switch state { 668 case backend.StateUnvetted: 669 // Comment writes are allowed 670 return nil 671 case backend.StateVetted: 672 // Comment writes on vetted proposals depends on the 673 // proposal vote status. Continue to the vote status 674 // validation below. 675 default: 676 return errors.Errorf("unknown state: %v", state) 677 } 678 679 // Validate vote status 680 vs, err := p.voteSummary(token) 681 if err != nil { 682 return err 683 } 684 switch vs.Status { 685 case ticketvote.VoteStatusUnauthorized, ticketvote.VoteStatusAuthorized, 686 ticketvote.VoteStatusStarted: 687 // Comment writes are allowed on these vote statuses 688 return nil 689 690 case ticketvote.VoteStatusApproved: 691 return p.writesAllowedOnApprovedProposal(token, cmd, payload) 692 693 default: 694 // Vote status does not allow writes 695 return backend.PluginError{ 696 PluginID: pi.PluginID, 697 ErrorCode: uint32(pi.ErrorCodeCommentWriteNotAllowed), 698 ErrorContext: "vote has ended; comments are locked", 699 } 700 } 701 } 702 703 // tokenDecode returns the decoded censorship token. An error will be returned 704 // if the token is not a full length token. 705 func tokenDecode(token string) ([]byte, error) { 706 return util.TokenDecode(util.TokenTypeTstore, token) 707 } 708 709 // proposalMetadataDecode decodes and returns the ProposalMetadata from the 710 // provided backend files. If a ProposalMetadata is not found, nil is returned. 711 func proposalMetadataDecode(files []backend.File) (*pi.ProposalMetadata, error) { 712 var propMD *pi.ProposalMetadata 713 for _, v := range files { 714 if v.Name != pi.FileNameProposalMetadata { 715 continue 716 } 717 b, err := base64.StdEncoding.DecodeString(v.Payload) 718 if err != nil { 719 return nil, err 720 } 721 var m pi.ProposalMetadata 722 err = json.Unmarshal(b, &m) 723 if err != nil { 724 return nil, err 725 } 726 propMD = &m 727 break 728 } 729 return propMD, nil 730 } 731 732 // voteMetadataDecode decodes and returns the VoteMetadata from the 733 // provided backend files. If a VoteMetadata is not found, nil is returned. 734 func voteMetadataDecode(files []backend.File) (*ticketvote.VoteMetadata, error) { 735 var voteMD *ticketvote.VoteMetadata 736 for _, v := range files { 737 if v.Name != ticketvote.FileNameVoteMetadata { 738 continue 739 } 740 b, err := base64.StdEncoding.DecodeString(v.Payload) 741 if err != nil { 742 return nil, err 743 } 744 var m ticketvote.VoteMetadata 745 err = json.Unmarshal(b, &m) 746 if err != nil { 747 return nil, err 748 } 749 voteMD = &m 750 break 751 } 752 return voteMD, nil 753 }