From db05d2b86fa2d75f162f5c3f83a21ab8552b452a Mon Sep 17 00:00:00 2001 From: Masala Date: Fri, 15 Mar 2019 17:58:19 -0400 Subject: [PATCH 01/26] no negative payouts --- backend/grant/proposal/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 1224ed5a..7c80998c 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -299,7 +299,7 @@ class Proposal(db.Model): if len(milestone.content) > 200: raise ValidationException("Milestone content must be no more than 200 chars") - payout_total += float(milestone.payout_percent) + payout_total += abs(float(milestone.payout_percent)) try: present = datetime.datetime.today().replace(day=1) From 7703d274bdfe94c42ab5b9d3c98214d9669947eb Mon Sep 17 00:00:00 2001 From: Masala Date: Fri, 15 Mar 2019 19:17:55 -0400 Subject: [PATCH 02/26] raise ValidationException on invalid payout percent --- backend/grant/proposal/models.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 7c80998c..91dd7250 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -299,7 +299,15 @@ class Proposal(db.Model): if len(milestone.content) > 200: raise ValidationException("Milestone content must be no more than 200 chars") - payout_total += abs(float(milestone.payout_percent)) + try: + p = float(milestone.payout_percent) + if p <= 0: + raise ValidationException("Milestone payout percent must be greater than zero") + + except ValueError: + raise ValidationException("Milestone payout percent must be a number") + + payout_total += p try: present = datetime.datetime.today().replace(day=1) From 3baa6d258f8c9427e770c14c6252f07aa67326e4 Mon Sep 17 00:00:00 2001 From: Masala Date: Fri, 15 Mar 2019 19:26:15 -0400 Subject: [PATCH 03/26] add payout percent frontend validation --- frontend/client/modules/create/utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 123f6343..27ed8d5e 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -134,6 +134,8 @@ export function getCreateErrors( return 'Payout percent is required'; } else if (Number.isNaN(parseInt(ms.payoutPercent, 10))) { return 'Payout percent must be a valid number'; + } else if (parseInt(ms.payoutPercent, 10) <= 0) { + return 'Payout percent must be greater than zero'; } // Last one shows percentage errors From ba1b54d979475058393c96d0477cd4d6ee537ed2 Mon Sep 17 00:00:00 2001 From: Masala Date: Fri, 15 Mar 2019 19:31:59 -0400 Subject: [PATCH 04/26] add check for payout percent > 100 --- frontend/client/modules/create/utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 27ed8d5e..dd9399f9 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -136,6 +136,8 @@ export function getCreateErrors( return 'Payout percent must be a valid number'; } else if (parseInt(ms.payoutPercent, 10) <= 0) { return 'Payout percent must be greater than zero'; + } else if (parseInt(ms.payoutPercent, 10) > 100) { + return 'Payout percent must be less than or equal to 100'; } // Last one shows percentage errors From 6fc6fedab7cdbd4c7ba638cc56bae0db3bdfc50e Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Mon, 18 Mar 2019 12:26:57 -0400 Subject: [PATCH 05/26] Prevent brief from overflowing. --- .../components/Profile/ProfileInvite.less | 3 +++ frontend/client/styles/antd-overrides.less | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/frontend/client/components/Profile/ProfileInvite.less b/frontend/client/components/Profile/ProfileInvite.less index 22c82983..e125a882 100644 --- a/frontend/client/components/Profile/ProfileInvite.less +++ b/frontend/client/components/Profile/ProfileInvite.less @@ -7,6 +7,9 @@ margin-bottom: 1rem; &-info { + min-width: 0; + padding-right: 2rem; + &-title { font-size: 1.2rem; font-weight: 600; diff --git a/frontend/client/styles/antd-overrides.less b/frontend/client/styles/antd-overrides.less index b57ba7a1..e67a80a3 100644 --- a/frontend/client/styles/antd-overrides.less +++ b/frontend/client/styles/antd-overrides.less @@ -16,3 +16,20 @@ div.antd-pro-ellipsis-ellipsis { word-break: break-word; } + +// List items with long content can push the actions aside +.ant-list-item { + overflow: hidden; + + .ant-list-item-content, + .ant-list-item-meta, + .ant-list-item-meta-content { + min-width: 0; + } + + .ant-list-item-meta-title, + .ant-list-item-meta-description { + overflow: hidden; + text-overflow: ellipsis; + } +} \ No newline at end of file From 2eff058add540a3d98842a397b63d57c03fcdc77 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Mon, 18 Mar 2019 12:43:30 -0400 Subject: [PATCH 06/26] Rehydrate proposal detail page milestones. --- frontend/client/utils/api.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/client/utils/api.ts b/frontend/client/utils/api.ts index 0d0f4a89..984d1ffa 100644 --- a/frontend/client/utils/api.ts +++ b/frontend/client/utils/api.ts @@ -149,6 +149,10 @@ export function massageSerializedState(state: AppState) { ); state.proposal.detail.contributionBounty = new BN((state.proposal.detail .contributionBounty as any) as string); + state.proposal.detail.milestones = state.proposal.detail.milestones.map(m => ({ + ...m, + amount: new BN((m.amount as any) as string, 16), + })); if (state.proposal.detail.rfp && state.proposal.detail.rfp.bounty) { state.proposal.detail.rfp.bounty = new BN( (state.proposal.detail.rfp.bounty as any) as string, From b0c6614a6454009990f073db88c803eaea4b36bb Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Mon, 18 Mar 2019 12:51:46 -0400 Subject: [PATCH 07/26] Dont allow inviting members already on the team (including yourself.) --- backend/grant/proposal/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 3893a98c..8064b501 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -379,6 +379,10 @@ def post_proposal_update(proposal_id, title, content): "address": fields.Str(required=True), }) def post_proposal_team_invite(proposal_id, address): + for u in g.current_proposal.team: + if address == u.email_address: + return {"message": f"Cannot invite members already on the team"}, 400 + existing_invite = ProposalTeamInvite.query.filter_by( proposal_id=proposal_id, address=address From adc2fd4d63c4aa9d36be6b3ef7113e23406eccb3 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Mon, 18 Mar 2019 14:35:08 -0400 Subject: [PATCH 08/26] Stricter validation, truncate before db entry, env var proposal target limit --- backend/.env.example | 5 +- backend/grant/milestone/models.py | 11 ++--- backend/grant/proposal/models.py | 48 +++++++++++++------ backend/grant/proposal/views.py | 3 +- backend/grant/settings.py | 1 + frontend/.env.example | 5 +- .../client/components/CreateFlow/Basics.tsx | 3 ++ .../client/components/CreateFlow/Details.tsx | 6 ++- .../client/components/CreateFlow/index.less | 4 ++ .../client/components/CreateFlow/index.tsx | 26 ++++++---- frontend/client/modules/create/utils.ts | 22 +++++++-- frontend/config/env.js | 1 + 12 files changed, 95 insertions(+), 40 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 736aa204..d4c83bff 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,5 +32,8 @@ BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca" # EXPLORER_URL="https://chain.so/tx/ZEC/" EXPLORER_URL="https://chain.so/tx/ZECTEST/" -# Amount for staking a proposal in ZEC +# Amount for staking a proposal in ZEC, keep in sync with frontend .env PROPOSAL_STAKING_AMOUNT=0.025 + +# Maximum amount for a proposal target, keep in sync with frontend .env +PROPOSAL_TARGET_MAX=10000 diff --git a/backend/grant/milestone/models.py b/backend/grant/milestone/models.py index cab8d261..36f5976d 100644 --- a/backend/grant/milestone/models.py +++ b/backend/grant/milestone/models.py @@ -70,21 +70,16 @@ class Milestone(db.Model): [db.session.delete(x) for x in proposal.milestones] for i, milestone_data in enumerate(milestones_data): m = Milestone( - title=milestone_data["title"], - content=milestone_data["content"], + title=milestone_data["title"][:255], + content=milestone_data["content"][:255], date_estimated=datetime.datetime.fromtimestamp(milestone_data["date_estimated"]), - payout_percent=str(milestone_data["payout_percent"]), + payout_percent=str(milestone_data["payout_percent"])[:255], immediate_payout=milestone_data["immediate_payout"], proposal_id=proposal.id, index=i ) db.session.add(m) - @staticmethod - def validate(milestone): - if len(milestone.title) > 60: - raise ValidationException("Milestone title must be no more than 60 chars") - def request_payout(self, user_id: int): if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]: raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage') diff --git a/backend/grant/proposal/models.py b/backend/grant/proposal/models.py index 1224ed5a..90a481ad 100644 --- a/backend/grant/proposal/models.py +++ b/backend/grant/proposal/models.py @@ -11,7 +11,7 @@ from flask import current_app from grant.comment.models import Comment from grant.email.send import send_email from grant.extensions import ma, db -from grant.settings import PROPOSAL_STAKING_AMOUNT +from grant.settings import PROPOSAL_STAKING_AMOUNT, PROPOSAL_TARGET_MAX from grant.task.jobs import ContributionExpired from grant.utils.enums import ( ProposalStatus, @@ -275,12 +275,11 @@ class Proposal(db.Model): @staticmethod def simple_validate(proposal): - title = proposal.get('title') + # Validate fields to be database save-able. + # Stricter validation is done in validate_publishable. stage = proposal.get('stage') category = proposal.get('category') - if title and len(title) > 60: - raise ValidationException("Proposal title cannot be longer than 60 characters") if stage and not ProposalStage.includes(stage): raise ValidationException("Proposal stage {} is not a valid stage".format(stage)) if category and not Category.includes(category): @@ -294,37 +293,56 @@ class Proposal(db.Model): raise ValidationException("Only the first milestone can have an immediate payout") if len(milestone.title) > 60: - raise ValidationException("Milestone title must be no more than 60 chars") + raise ValidationException("Milestone title cannot be longer than 60 chars") if len(milestone.content) > 200: - raise ValidationException("Milestone content must be no more than 200 chars") + raise ValidationException("Milestone content cannot be longer than 200 chars") payout_total += float(milestone.payout_percent) try: present = datetime.datetime.today().replace(day=1) if present > milestone.date_estimated: - raise ValidationException("Milestone date_estimated must be in the future ") + raise ValidationException("Milestone date estimate must be in the future ") except Exception as e: current_app.logger.warn( f"Unexpected validation error - client prohibits {e}" ) - raise ValidationException("date_estimated does not convert to a datetime") + raise ValidationException("Date estimate is not a valid datetime") if payout_total != 100.0: - raise ValidationException("payoutPercent across milestones must sum to exactly 100") + raise ValidationException("Payout percentages of milestones must add up to exactly 100%") def validate_publishable(self): self.validate_publishable_milestones() # Require certain fields - required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address'] for field in required_fields: if not hasattr(self, field): raise ValidationException("Proposal must have a {}".format(field)) + # Stricter limits on certain fields + title = proposal.get('title') + brief = proposal.get('brief') + content = proposal.get('content') + target = proposal.get('target') + deadline_duration = proposal.get('deadline_duration') + + if len(title) > 60: + raise ValidationException("Proposal title cannot be longer than 60 characters") + if len(brief) > 140: + raise ValidationException("Brief cannot be longer than 140 characters") + if len(content) > 250000: + raise ValidationException("Content cannot be longer than 250,000 characters") + if Decimal(target) > PROPOSAL_TARGET_MAX: + raise ValidationException("Target cannot be more than {} ZEC".format(PROPOSAL_TARGET_MAX)) + if Decimal(target) < 0.0001: + raise ValidationException("Target cannot be less than 0.0001") + if deadline_duration > 7776000: + raise ValidationException("Deadline duration cannot be more than 90 days") + # Check with node that the address is kosher try: res = blockchain_get('/validate/address', {'address': self.payout_address}) @@ -380,12 +398,12 @@ class Proposal(db.Model): payout_address: str = '', deadline_duration: int = 5184000 # 60 days ): - self.title = title - self.brief = brief + self.title = title[:255] + self.brief = brief[:255] self.category = category - self.content = content - self.target = target if target != '' else None - self.payout_address = payout_address + self.content = content[:300000] + self.target = target[:255] if target != '' else None + self.payout_address = payout_address[:255] self.deadline_duration = deadline_duration Proposal.simple_validate(vars(self)) diff --git a/backend/grant/proposal/views.py b/backend/grant/proposal/views.py index 3893a98c..ecd5a454 100644 --- a/backend/grant/proposal/views.py +++ b/backend/grant/proposal/views.py @@ -218,6 +218,7 @@ def get_proposal_drafts(): @blueprint.route("/", methods=["PUT"]) @requires_team_member_auth @body({ + # Length checks are to prevent database errors, not actual user limits imposed "title": fields.Str(required=True), "brief": fields.Str(required=True), "category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list() + [''])), @@ -226,7 +227,7 @@ def get_proposal_drafts(): "payoutAddress": fields.Str(required=True), "deadlineDuration": fields.Int(required=True), "milestones": fields.List(fields.Dict(), required=True), - "rfpOptIn": fields.Bool(required=False, missing=None) + "rfpOptIn": fields.Bool(required=False, missing=None), }) def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs): # Update the base proposal fields diff --git a/backend/grant/settings.py b/backend/grant/settings.py index 7aa6780a..d82b013a 100644 --- a/backend/grant/settings.py +++ b/backend/grant/settings.py @@ -61,6 +61,7 @@ BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET") EXPLORER_URL = env.str("EXPLORER_URL", default="https://chain.so/tx/ZECTEST/") PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT")) +PROPOSAL_TARGET_MAX = Decimal(env.str("PROPOSAL_TARGET_MAX")) UI = { 'NAME': 'ZF Grants', diff --git a/frontend/.env.example b/frontend/.env.example index 186f9d4f..83333a15 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -16,7 +16,7 @@ BACKEND_URL=http://localhost:5000 # EXPLORER_URL="https://chain.so/tx/ZEC/" EXPLORER_URL="https://chain.so/tx/ZECTEST/" -# Amount for staking a proposal in ZEC +# Amount for staking a proposal in ZEC, keep in sync with backend .env PROPOSAL_STAKING_AMOUNT=0.025 # Normally production runs with SSL, this disables that @@ -24,3 +24,6 @@ DISABLE_SSL=true # Uncomment if running on testnet # TESTNET=true + +# Maximum amount for a proposal target, keep in sync with backend .env +PROPOSAL_TARGET_MAX=10000 diff --git a/frontend/client/components/CreateFlow/Basics.tsx b/frontend/client/components/CreateFlow/Basics.tsx index ab064d83..017988e3 100644 --- a/frontend/client/components/CreateFlow/Basics.tsx +++ b/frontend/client/components/CreateFlow/Basics.tsx @@ -147,6 +147,7 @@ class CreateFlowBasics extends React.Component { type="text" value={title} onChange={this.handleInputChange} + maxLength={200} /> @@ -161,6 +162,7 @@ class CreateFlowBasics extends React.Component { value={brief} onChange={this.handleInputChange} rows={3} + maxLength={200} /> @@ -196,6 +198,7 @@ class CreateFlowBasics extends React.Component { value={target} onChange={this.handleInputChange} addonAfter="ZEC" + maxLength={20} /> diff --git a/frontend/client/components/CreateFlow/Details.tsx b/frontend/client/components/CreateFlow/Details.tsx index ebd145bb..070e504b 100644 --- a/frontend/client/components/CreateFlow/Details.tsx +++ b/frontend/client/components/CreateFlow/Details.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { Form } from 'antd'; +import { Form, Alert } from 'antd'; import MarkdownEditor from 'components/MarkdownEditor'; import { ProposalDraft } from 'types'; +import { getCreateErrors } from 'modules/create/utils'; interface State { content: string; @@ -22,6 +23,8 @@ export default class CreateFlowTeam extends React.Component { } render() { + const errors = getCreateErrors(this.state, true); + return (
{ initialMarkdown={this.state.content} minHeight={200} /> + {errors.content && } ); } diff --git a/frontend/client/components/CreateFlow/index.less b/frontend/client/components/CreateFlow/index.less index 3ffec37e..9d28fa23 100644 --- a/frontend/client/components/CreateFlow/index.less +++ b/frontend/client/components/CreateFlow/index.less @@ -138,6 +138,10 @@ font-size: 0.8rem; opacity: 0.3; animation: draft-notification-popup 120ms ease 1; + + &.is-error { + color: @error-color; + } } &-loading { diff --git a/frontend/client/components/CreateFlow/index.tsx b/frontend/client/components/CreateFlow/index.tsx index 8de21124..2c4142ea 100644 --- a/frontend/client/components/CreateFlow/index.tsx +++ b/frontend/client/components/CreateFlow/index.tsx @@ -103,6 +103,7 @@ interface StateProps { form: AppState['create']['form']; isSavingDraft: AppState['create']['isSavingDraft']; hasSavedDraft: AppState['create']['hasSavedDraft']; + saveDraftError: AppState['create']['saveDraftError']; } interface DispatchProps { @@ -149,7 +150,7 @@ class CreateFlow extends React.Component { } render() { - const { isSavingDraft } = this.props; + const { isSavingDraft, saveDraftError } = this.props; const { step, isPreviewing, isSubmitting, isShowingSubmitWarning } = this.state; const info = STEP_INFO[step]; @@ -238,8 +239,16 @@ class CreateFlow extends React.Component { )} )} - {isSavingDraft && ( + {isSavingDraft ? (
Saving draft...
+ ) : ( + saveDraftError && ( +
+ Failed to save draft! +
+ {saveDraftError} +
+ ) )} { } const withConnect = connect( - (state: AppState) => { - return { - form: state.create.form, - isSavingDraft: state.create.isSavingDraft, - hasSavedDraft: state.create.hasSavedDraft, - }; - }, + (state: AppState) => ({ + form: state.create.form, + isSavingDraft: state.create.isSavingDraft, + hasSavedDraft: state.create.hasSavedDraft, + saveDraftError: state.create.saveDraftError, + }), { updateForm: createActions.updateForm, }, diff --git a/frontend/client/modules/create/utils.ts b/frontend/client/modules/create/utils.ts index 123f6343..f2963f40 100644 --- a/frontend/client/modules/create/utils.ts +++ b/frontend/client/modules/create/utils.ts @@ -13,8 +13,6 @@ import { PROPOSAL_DETAIL_INITIAL_STATE, } from 'modules/proposals/reducers'; -export const TARGET_ZEC_LIMIT = 1000; - interface CreateFormErrors { rfpOptIn?: string; title?: string; @@ -57,7 +55,17 @@ export function getCreateErrors( skipRequired?: boolean, ): CreateFormErrors { const errors: CreateFormErrors = {}; - const { title, team, milestones, target, payoutAddress, rfp, rfpOptIn, brief } = form; + const { + title, + content, + team, + milestones, + target, + payoutAddress, + rfp, + rfpOptIn, + brief, + } = form; // Required fields with no extra validation if (!skipRequired) { @@ -90,10 +98,16 @@ export function getCreateErrors( errors.brief = 'Brief can only be 140 characters maximum'; } + // Content limit for our database's sake + if (content && content.length > 250000) { + errors.content = 'Details can only be 250,000 characters maximum'; + } + // Amount to raise const targetFloat = target ? parseFloat(target) : 0; if (target && !Number.isNaN(targetFloat)) { - const targetErr = getAmountError(targetFloat, TARGET_ZEC_LIMIT); + const limit = parseFloat(process.env.PROPOSAL_TARGET_MAX as string); + const targetErr = getAmountError(targetFloat, limit); if (targetErr) { errors.target = targetErr; } diff --git a/frontend/config/env.js b/frontend/config/env.js index 55017af3..a88cafec 100644 --- a/frontend/config/env.js +++ b/frontend/config/env.js @@ -61,6 +61,7 @@ module.exports = () => { SENTRY_DSN: process.env.SENTRY_DSN || null, SENTRY_RELEASE: process.env.SENTRY_RELEASE || undefined, TESTNET: process.env.TESTNET || false, + PROPOSAL_TARGET_MAX: process.env.PROPOSAL_TARGET_MAX || '10000', }; // Stringify all values so we can feed into Webpack DefinePlugin From 0ce35629a2f414aa0780e0b26e7e3cb1ccc625f4 Mon Sep 17 00:00:00 2001 From: Will O'Beirne Date: Mon, 18 Mar 2019 14:47:46 -0400 Subject: [PATCH 09/26] More component maxlengths --- frontend/client/components/CreateFlow/Basics.tsx | 2 +- frontend/client/components/CreateFlow/Milestones.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/client/components/CreateFlow/Basics.tsx b/frontend/client/components/CreateFlow/Basics.tsx index 017988e3..2abbf5cf 100644 --- a/frontend/client/components/CreateFlow/Basics.tsx +++ b/frontend/client/components/CreateFlow/Basics.tsx @@ -198,7 +198,7 @@ class CreateFlowBasics extends React.Component { value={target} onChange={this.handleInputChange} addonAfter="ZEC" - maxLength={20} + maxLength={16} /> diff --git a/frontend/client/components/CreateFlow/Milestones.tsx b/frontend/client/components/CreateFlow/Milestones.tsx index 15b830eb..2766a739 100644 --- a/frontend/client/components/CreateFlow/Milestones.tsx +++ b/frontend/client/components/CreateFlow/Milestones.tsx @@ -124,6 +124,7 @@ const MilestoneFields = ({ name="title" value={milestone.title} onChange={ev => onChange(index, { ...milestone, title: ev.currentTarget.value })} + maxLength={80} />