diff --git a/mdstream/mdstream.go b/mdstream/mdstream.go index b113397564..0c93740320 100644 --- a/mdstream/mdstream.go +++ b/mdstream/mdstream.go @@ -21,28 +21,30 @@ import ( const ( // mdstream IDs - IDProposalGeneral = 0 - IDRecordStatusChange = 2 - IDInvoiceGeneral = 3 - IDInvoiceStatusChange = 4 - IDInvoicePayment = 5 - IDDCCGeneral = 6 - IDDCCStatusChange = 7 - IDDCCSupportOpposition = 8 + IDProposalGeneral = 0 + IDRecordStatusChange = 2 + IDInvoiceGeneral = 3 + IDInvoiceStatusChange = 4 + IDInvoicePayment = 5 + IDDCCGeneral = 6 + IDDCCStatusChange = 7 + IDDCCSupportOpposition = 8 + IDInvoiceProposalApprove = 9 // Note that 13 is in use by the decred plugin // Note that 14 is in use by the decred plugin // Note that 15 is in use by the decred plugin // mdstream current supported versions - VersionProposalGeneral = 2 - VersionRecordStatusChange = 2 - VersionInvoiceGeneral = 1 - VersionInvoiceStatusChange = 1 - VersionInvoicePayment = 1 - VersionDCCGeneral = 1 - VersionDCCStatusChange = 1 - VersionDCCSupposeOpposition = 1 + VersionProposalGeneral = 2 + VersionRecordStatusChange = 2 + VersionInvoiceGeneral = 1 + VersionInvoiceStatusChange = 1 + VersionInvoicePayment = 1 + VersionDCCGeneral = 1 + VersionDCCStatusChange = 1 + VersionDCCSupposeOpposition = 1 + VersionInvoiceProposalApprove = 1 // Filenames of user defined metadata that is stored as politeiad // files instead of politeiad metadata streams. This is done so @@ -531,3 +533,47 @@ func DecodeDCCSupportOpposition(payload []byte) ([]DCCSupportOpposition, error) return md, nil } + +// InvoiceProposalApprove represents an invoice status change and is stored +// in the metadata IDInvoiceProposalApprove in politeiad. +type InvoiceProposalApprove struct { + Version uint `json:"version"` // Version of the struct + PublicKey string `json:"publickey"` // Identity of the administrator + Signature string `json:"signature"` // Signature of the line item payload included + Token string `json:"token"` // Token of the invoice + Timestamp int64 `json:"timestamp"` + LineItems []byte `json:"lineitems"` // json payload of line items that are being approved + InvoiceVersion string `json:"invoiceversion"` // Version of the invoice that is being approved +} + +// EncodeInvoiceProposalApprove encodes a InvoiceProposalApprove into a +// JSON byte slice. +func EncodeInvoiceProposalApprove(md InvoiceProposalApprove) ([]byte, error) { + b, err := json.Marshal(md) + if err != nil { + return nil, err + } + + return b, nil +} + +// DecodeInvoiceProposalApprove decodes a JSON byte slice into a slice of +// InvoiceProposalApproves. +func DecodeInvoiceProposalApprove(payload []byte) ([]InvoiceProposalApprove, error) { + var md []InvoiceProposalApprove + + d := json.NewDecoder(strings.NewReader(string(payload))) + for { + var m InvoiceProposalApprove + err := d.Decode(&m) + if err == io.EOF { + break + } else if err != nil { + return nil, err + } + + md = append(md, m) + } + + return md, nil +} diff --git a/politeiawww/api/cms/v1/v1.go b/politeiawww/api/cms/v1/v1.go index a6d296e715..f0af9e0402 100644 --- a/politeiawww/api/cms/v1/v1.go +++ b/politeiawww/api/cms/v1/v1.go @@ -55,6 +55,7 @@ const ( RouteProposalBillingSummary = "/proposals/spendingsummary" RouteProposalBillingDetails = "/proposals/spendingdetails" RouteUserCodeStats = "/user/codestats" + RouteProposalInvoiceApprove = "/proposals/approve" // Invoice status codes InvoiceStatusInvalid InvoiceStatusT = 0 // Invalid status @@ -469,6 +470,7 @@ type InvoiceRecord struct { Input InvoiceInput `json:"input"` // Decoded invoice from invoice.json file Payment PaymentInformation `json:"payment"` // Payment information for the Invoice Total int64 `json:"total"` // Total amount that the invoice is billing + ApprovedProposals []string `json:"approvedproposals"` // List of proposals from this invoice that have been approved CensorshipRecord www.CensorshipRecord `json:"censorshiprecord"` } @@ -511,6 +513,7 @@ type LineItemsInput struct { SubRate uint `json:"subrate"` // The payrate of the subcontractor Labor uint `json:"labor"` // Number of minutes (if labor) Expenses uint `json:"expenses"` // Total cost (in USD cents) of line item (if expense or misc) + Approved bool `json:"approved"` // Proposal owner approved this line item (if proposal token specified) } // PolicyReply returns the various policy information while in CMS mode. @@ -1085,3 +1088,17 @@ type CodeStats struct { Reviews []string `json:"reviews"` Commits []string `json:"commits"` } + +// ProposalOwnerApprove is used to approve or reject an proposal referenced +// invoice. +type ProposalOwnerApprove struct { + Token string `json:"token"` + Status InvoiceStatusT `json:"status"` + LineItems []LineItemsInput `json:"lineitems"` + Signature string `json:"signature"` // Signature of LineItems Approved + PublicKey string `json:"publickey"` // Public key of admin +} + +// ProposalOwnerApprove used to reply to a ProposalOwnerApprove command. +type ProposalOwnerApproveReply struct { +} diff --git a/politeiawww/cmsdatabase/cockroachdb/cockroachdb.go b/politeiawww/cmsdatabase/cockroachdb/cockroachdb.go index a24ffac4a8..c94b2d831a 100644 --- a/politeiawww/cmsdatabase/cockroachdb/cockroachdb.go +++ b/politeiawww/cmsdatabase/cockroachdb/cockroachdb.go @@ -630,7 +630,8 @@ func (c *cockroachdb) InvoicesByLineItemsProposalToken(token string) ([]database line_items.labor, line_items.expenses, line_items.contractor_rate AS sub_rate, - line_items.sub_user_id as sub_user + line_items.sub_user_id AS sub_user, + line_items.approved FROM invoices LEFT OUTER JOIN invoices b ON invoices.token = b.token @@ -678,6 +679,7 @@ type MatchingLineItems struct { ExchangeRate uint SubRate uint SubUser string + Approved bool } // Close satisfies the database interface. diff --git a/politeiawww/cmsdatabase/cockroachdb/encoding.go b/politeiawww/cmsdatabase/cockroachdb/encoding.go index 539b7ce001..da7ea6853e 100644 --- a/politeiawww/cmsdatabase/cockroachdb/encoding.go +++ b/politeiawww/cmsdatabase/cockroachdb/encoding.go @@ -114,6 +114,7 @@ func EncodeInvoiceLineItem(dbLineItem *database.LineItem) LineItem { lineItem.Expenses = dbLineItem.Expenses lineItem.ContractorRate = dbLineItem.ContractorRate lineItem.SubUserID = dbLineItem.SubUserID + lineItem.Approved = dbLineItem.Approved return lineItem } @@ -130,6 +131,7 @@ func DecodeInvoiceLineItem(lineItem *LineItem) *database.LineItem { dbLineItem.Expenses = lineItem.Expenses dbLineItem.ContractorRate = lineItem.ContractorRate dbLineItem.SubUserID = lineItem.SubUserID + dbLineItem.Approved = lineItem.Approved return dbLineItem } @@ -274,6 +276,7 @@ func convertMatchingLineItemToInvoices(matching []MatchingLineItems) []database. ProposalURL: vv.ProposalURL, ContractorRate: vv.SubRate, SubUserID: vv.SubUser, + Approved: vv.Approved, } inv := database.Invoice{ PublicKey: vv.PublicKey, diff --git a/politeiawww/cmsdatabase/cockroachdb/models.go b/politeiawww/cmsdatabase/cockroachdb/models.go index de5245f3e2..0921f43d14 100644 --- a/politeiawww/cmsdatabase/cockroachdb/models.go +++ b/politeiawww/cmsdatabase/cockroachdb/models.go @@ -69,6 +69,7 @@ type LineItem struct { Expenses uint `gorm:"not null"` // Total cost of line item (in USD cents) ContractorRate uint `gorm:"not null"` // Optional contractor rate for line item, typically used for Sub Contractors SubUserID string `gorm:"not null"` // SubContractor User ID if Subcontractor Line Item + Approved bool `gorm:"not null"` // Proposal owner approved line item } // TableName returns the table name of the line items table. diff --git a/politeiawww/cmsdatabase/database.go b/politeiawww/cmsdatabase/database.go index 92bda83c16..445eefb3ee 100644 --- a/politeiawww/cmsdatabase/database.go +++ b/politeiawww/cmsdatabase/database.go @@ -108,6 +108,7 @@ type Invoice struct { ContractorContact string ContractorRate uint PaymentAddress string + ApprovedProposals []string LineItems []LineItem // All line items parsed from the raw invoice provided. Changes []InvoiceChange // All status changes that the invoice has had. @@ -128,6 +129,7 @@ type LineItem struct { Expenses uint ContractorRate uint SubUserID string + Approved bool } // InvoiceChange contains entries for any status update that occurs to a given diff --git a/politeiawww/cmswww.go b/politeiawww/cmswww.go index 9ba8db7b3b..ded70e6f7c 100644 --- a/politeiawww/cmswww.go +++ b/politeiawww/cmswww.go @@ -1125,6 +1125,37 @@ func (p *politeiawww) handleUserCodeStats(w http.ResponseWriter, r *http.Request util.RespondWithJSON(w, http.StatusOK, uscr) } +// handleProposalInvoiceApprove handles request for proposal owners to approve +// an invoices' line items that reference their proposal. +func (p *politeiawww) handleProposalInvoiceApprove(w http.ResponseWriter, r *http.Request) { + log.Tracef("handleProposalInvoiceApprove") + + var poa cms.ProposalOwnerApprove + decoder := json.NewDecoder(r.Body) + if err := decoder.Decode(&poa); err != nil { + RespondWithError(w, r, 0, "handleProposalInvoiceApprove: unmarshal", + www.UserError{ + ErrorCode: www.ErrorStatusInvalidInput, + }) + return + } + user, err := p.getSessionUser(w, r) + if err != nil { + RespondWithError(w, r, 0, + "handleProposalInvoiceApprove: getSessionUser %v", err) + return + } + + poar, err := p.processProposalInvoiceApprove(r.Context(), poa, user) + if err != nil { + RespondWithError(w, r, 0, + "handleProposalInvoiceApprove: processStartVoteDCC %v", err) + return + } + + util.RespondWithJSON(w, http.StatusOK, poar) +} + func (p *politeiawww) setCMSWWWRoutes() { // Templates //p.addTemplate(templateNewProposalSubmittedName, @@ -1224,6 +1255,9 @@ func (p *politeiawww) setCMSWWWRoutes() { p.addRoute(http.MethodPost, cms.APIRoute, cms.RouteUserCodeStats, p.handleUserCodeStats, permissionLogin) + p.addRoute(http.MethodPost, cms.APIRoute, + cms.RouteProposalInvoiceApprove, p.handleProposalInvoiceApprove, + permissionLogin) // Unauthenticated websocket p.addRoute("", www.PoliteiaWWWAPIRoute, diff --git a/politeiawww/comments.go b/politeiawww/comments.go index 8792bc68d3..6281af71b4 100644 --- a/politeiawww/comments.go +++ b/politeiawww/comments.go @@ -367,8 +367,23 @@ func (p *politeiawww) processNewCommentInvoice(ctx context.Context, nc www.NewCo // Check to make sure the user is either an admin or the // author of the invoice. if !u.Admin && (ir.Username != u.Username) { - return nil, www.UserError{ - ErrorCode: www.ErrorStatusUserActionNotAllowed, + // If not an admin or invoice owner, check to see if they own a + // proposal that is being billed against, they are allowed to comment. + cmsUser, err := p.getCMSUserByID(u.ID.String()) + if err != nil { + return nil, err + } + + proposalFound := false + for _, lineItem := range ir.Input.LineItems { + if stringInSlice(cmsUser.ProposalsOwned, lineItem.ProposalToken) { + proposalFound = true + } + } + if !proposalFound { + return nil, www.UserError{ + ErrorCode: www.ErrorStatusUserActionNotAllowed, + } } } diff --git a/politeiawww/convert.go b/politeiawww/convert.go index 5a87675541..3ed6d41b4b 100644 --- a/politeiawww/convert.go +++ b/politeiawww/convert.go @@ -871,6 +871,7 @@ func convertDatabaseInvoiceToInvoiceRecord(dbInvoice cmsdatabase.Invoice) (cms.I Expenses: dbLineItem.Expenses, SubRate: dbLineItem.ContractorRate, SubUserID: dbLineItem.SubUserID, + Approved: dbLineItem.Approved, } invInputLineItems = append(invInputLineItems, lineItem) } @@ -926,6 +927,7 @@ func convertInvoiceRecordToDatabaseInvoice(invRec *cms.InvoiceRecord) *cmsdataba Expenses: lineItem.Expenses, ContractorRate: lineItem.SubRate, SubUserID: lineItem.SubUserID, + Approved: lineItem.Approved, } dbInvoice.LineItems = append(dbInvoice.LineItems, dbLineItem) } @@ -947,6 +949,7 @@ func convertLineItemsToDatabase(token string, l []cms.LineItemsInput) []cmsdatab // If subrate is populated, use the existing contractor rate field. ContractorRate: v.SubRate, SubUserID: v.SubUserID, + Approved: v.Approved, }) } return dl @@ -963,6 +966,7 @@ func convertDatabaseToLineItems(dl []cmsdatabase.LineItem) []cms.LineItemsInput ProposalToken: v.ProposalURL, Labor: v.Labor, Expenses: v.Expenses, + Approved: v.Approved, }) } return l @@ -976,6 +980,7 @@ func convertRecordToDatabaseInvoice(p pd.Record) (*cmsdatabase.Invoice, error) { Version: p.Version, } + var approvedProposals []string // Decode invoice file for _, v := range p.Files { if v.Name == invoiceFile { @@ -1070,6 +1075,28 @@ func convertRecordToDatabaseInvoice(p pd.Record) (*cmsdatabase.Invoice, error) { payment.TimeLastUpdated = s.Timestamp payment.AmountReceived = s.AmountReceived } + dbInvoice.Payments = payment + case mdstream.IDInvoiceProposalApprove: + ipa, err := mdstream.DecodeInvoiceProposalApprove([]byte(m.Payload)) + if err != nil { + log.Errorf("convertInvoiceFromCache: decode md stream: "+ + "token:%v error:%v payload:%v", + p.CensorshipRecord.Token, err, m) + continue + } + for _, s := range ipa { + // Check to see if the approved invoice version doesn't match + // current invoice version. If so, the proposal owner needs to + // approve again. + if s.InvoiceVersion != p.Version { + continue + } + if approvedProposals == nil { + approvedProposals = make([]string, 0, 1048) + } + approvedProposals = append(approvedProposals, s.Token) + } + default: // Log error but proceed log.Errorf("convertRecordToInvoiceDB: invalid "+ @@ -1079,6 +1106,15 @@ func convertRecordToDatabaseInvoice(p pd.Record) (*cmsdatabase.Invoice, error) { } dbInvoice.Payments = payment + // Set all line items to approved based on metadata + for _, approvedProposal := range approvedProposals { + for i := range dbInvoice.LineItems { + if dbInvoice.LineItems[i].ProposalURL == approvedProposal { + dbInvoice.LineItems[i].Approved = true + } + } + } + return &dbInvoice, nil } @@ -1408,6 +1444,7 @@ func convertDatabaseInvoiceToProposalLineItems(inv cmsdatabase.Invoice) cms.Prop Labor: inv.LineItems[0].Labor, Expenses: inv.LineItems[0].Expenses, SubRate: inv.LineItems[0].ContractorRate, + Approved: inv.LineItems[0].Approved, }, } } diff --git a/politeiawww/invoices.go b/politeiawww/invoices.go index 7634893ef7..4fa7360e9c 100644 --- a/politeiawww/invoices.go +++ b/politeiawww/invoices.go @@ -829,10 +829,46 @@ func (p *politeiawww) processInvoiceDetails(invDetails cms.InvoiceDetails, u *us // Check to make sure the user is either an admin or shares the domain // as the invoice creator (which will then be filtered). if !u.Admin && (invoiceUser.Domain != requestingUser.Domain) { - err := www.UserError{ + return nil, www.UserError{ ErrorCode: www.ErrorStatusUserActionNotAllowed, } - return nil, err + } + + // Check to make sure the reqeusting user is either an admin, the + // invoice author or the owner of a proposal of line items that are + // included. + proposalFound := false + if !u.Admin && (invRec.Username != u.Username) { + // This is a non invoice owner or admin requesting, clean it up for + // private information. + invRec.Files = nil + invRec.Payment = cms.PaymentInformation{} + invRec.Input.PaymentAddress = "" + invRec.Input.ContractorLocation = "" + + validLineItems := invRec.Input.LineItems[:0] + + for _, lineItem := range invRec.Input.LineItems { + // If the proposal token is empty then don't display it for the + // non invoice owner or admin. + if lineItem.ProposalToken == "" { + continue + } + // Check to see that proposal token matches an owned proposal by + // the requesting user, if not don't include the line item in the + // list of line items to display. + if stringInSlice(requestingUser.ProposalsOwned, lineItem.ProposalToken) { + proposalFound = true + validLineItems = append(validLineItems, lineItem) + } + } + invRec.Input.LineItems = validLineItems + if !proposalFound { + err := www.UserError{ + ErrorCode: www.ErrorStatusUserActionNotAllowed, + } + return nil, err + } } // Calculate the payout from the invoice record @@ -1577,10 +1613,25 @@ func (p *politeiawww) processInvoiceComments(token string, u *user.User) (*www.G // Check to make sure the user is either an admin or the // invoice author. if !u.Admin && (ir.Username != u.Username) { - err := www.UserError{ - ErrorCode: www.ErrorStatusUserActionNotAllowed, + // If not an admin or invoice owner, check to see if they own a + // proposal that is being billed against, they are allowed to view + // comment threads (that they have begun). + cmsUser, err := p.getCMSUserByID(u.ID.String()) + if err != nil { + return nil, err + } + + proposalFound := false + for _, lineItem := range ir.Input.LineItems { + if stringInSlice(cmsUser.ProposalsOwned, lineItem.ProposalToken) { + proposalFound = true + } + } + if !proposalFound { + return nil, www.UserError{ + ErrorCode: www.ErrorStatusUserActionNotAllowed, + } } - return nil, err } // Fetch proposal comments from cache @@ -1589,6 +1640,35 @@ func (p *politeiawww) processInvoiceComments(token string, u *user.User) (*www.G return nil, err } + userOwnedComments := make([]www.Comment, 0, 1048) + commentsToDisplay := make([]www.Comment, 0, 1048) + // Check to see if it's a proposal owner, then show them only threads + // they started. Admins and invoice owners can see everything. + if !u.Admin && (ir.Username != u.Username) { + for _, comment := range c { + // Add any comments that are owned by the requesting + // user. + if comment.UserID == u.ID.String() { + userOwnedComments = append(userOwnedComments, comment) + } + } + // Now go through again and check for any replies to a proposal owner + // comment. + for _, comment := range c { + addToComments := false + for _, userComment := range userOwnedComments { + if userComment.ParentID == comment.CommentID { + addToComments = true + break + } + } + if addToComments { + commentsToDisplay = append(commentsToDisplay, comment) + } + } + commentsToDisplay = append(commentsToDisplay, userOwnedComments...) + c = commentsToDisplay + } // Get the last time the user accessed these comments. This is // a public route so a user may not exist. var accessTime int64 @@ -1894,13 +1974,11 @@ func (p *politeiawww) processProposalBillingSummary(pbs cms.ProposalBillingSumma if err != nil { return nil, err } - var tvr www.TokenInventoryReply err = json.Unmarshal(data, &tvr) if err != nil { return nil, err } - approvedProposals := tvr.Approved approvedProposalDetails := make([]www.ProposalRecord, 0, len(approvedProposals)) @@ -2000,7 +2078,6 @@ func (p *politeiawww) processProposalBillingDetails(pbd cms.ProposalBillingDetai if err != nil { return nil, err } - spendingSummary := cms.ProposalSpending{} spendingSummary.Token = pbd.Token @@ -2030,7 +2107,6 @@ func (p *politeiawww) processProposalBillingDetails(pbd cms.ProposalBillingDetai if err != nil { return nil, err } - var pdr www.ProposalDetailsReply err = json.Unmarshal(data, &pdr) if err != nil { @@ -2044,3 +2120,105 @@ func (p *politeiawww) processProposalBillingDetails(pbd cms.ProposalBillingDetai reply.Details = spendingSummary return reply, nil } + +// processProposalInvoiceApprove appends a proposal owners approval onto the +// invoice records metadata which will allow admins to determine if an invoice +// is fully approved. +func (p *politeiawww) processProposalInvoiceApprove(ctx context.Context, poa cms.ProposalOwnerApprove, u *user.User) (*cms.ProposalOwnerApproveReply, error) { + invRec, err := p.getInvoice(poa.Token) + if err != nil { + return nil, err + } + cmsUser, err := p.getCMSUserByID(u.ID.String()) + if err != nil { + return nil, err + } + proposalFound := false + for i, lineItem := range invRec.Input.LineItems { + // If the proposal token is empty then don't display it for the + // non invoice owner or admin. + if lineItem.ProposalToken == "" { + continue + } + // Check to see that proposal token matches an owned proposal by + // the recommending user, if not don't include the line item in the + // list of line items to display. + if stringInSlice(cmsUser.ProposalsOwned, lineItem.ProposalToken) { + proposalFound = true + invRec.Input.LineItems[i].Approved = true + } + } + if !proposalFound { + err := www.UserError{ + ErrorCode: www.ErrorStatusUserActionNotAllowed, + } + return nil, err + } + + b, err := json.Marshal(poa.LineItems) + if err != nil { + return nil, err + } + + // Validate signature + msg := fmt.Sprintf("%v", b) + err = validateSignature(poa.PublicKey, poa.Signature, msg) + if err != nil { + return nil, err + } + + // Create the change record. + c := mdstream.InvoiceProposalApprove{ + Version: mdstream.VersionInvoiceProposalApprove, + PublicKey: u.PublicKey(), + Timestamp: time.Now().Unix(), + Signature: poa.Signature, + Token: poa.Token, + LineItems: b, + InvoiceVersion: invRec.Version, + } + blob, err := mdstream.EncodeInvoiceProposalApprove(c) + if err != nil { + return nil, err + } + challenge, err := util.Random(pd.ChallengeSize) + if err != nil { + return nil, err + } + + pdCommand := pd.UpdateVettedMetadata{ + Challenge: hex.EncodeToString(challenge), + Token: poa.Token, + MDAppend: []pd.MetadataStream{ + { + ID: mdstream.IDInvoiceProposalApprove, + Payload: string(blob), + }, + }, + } + + responseBody, err := p.makeRequest(ctx, http.MethodPost, pd.UpdateVettedMetadataRoute, pdCommand) + if err != nil { + return nil, err + } + + var pdReply pd.UpdateVettedMetadataReply + err = json.Unmarshal(responseBody, &pdReply) + if err != nil { + return nil, fmt.Errorf("Could not unmarshal UpdateVettedMetadataReply: %v", + err) + } + + // Verify the UpdateVettedMetadata challenge. + err = util.VerifyChallenge(p.cfg.Identity, challenge, pdReply.Response) + if err != nil { + return nil, err + } + + // Update Invoice in db so line items are approved + err = p.cmsDB.UpdateInvoice(convertInvoiceRecordToDatabaseInvoice(invRec)) + if err != nil { + return nil, err + } + return &cms.ProposalOwnerApproveReply{}, nil +}