Stricter validation, truncate before db entry, env var proposal target limit

This commit is contained in:
Will O'Beirne 2019-03-18 14:35:08 -04:00
parent ba704f5f5c
commit adc2fd4d63
No known key found for this signature in database
GPG Key ID: 44C190DB5DEAF9F6
12 changed files with 95 additions and 40 deletions

View File

@ -32,5 +32,8 @@ BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca"
# EXPLORER_URL="https://chain.so/tx/ZEC/<txid>"
EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
# 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

View File

@ -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')

View File

@ -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))

View File

@ -218,6 +218,7 @@ def get_proposal_drafts():
@blueprint.route("/<proposal_id>", 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

View File

@ -61,6 +61,7 @@ BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET")
EXPLORER_URL = env.str("EXPLORER_URL", default="https://chain.so/tx/ZECTEST/<txid>")
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))
PROPOSAL_TARGET_MAX = Decimal(env.str("PROPOSAL_TARGET_MAX"))
UI = {
'NAME': 'ZF Grants',

View File

@ -16,7 +16,7 @@ BACKEND_URL=http://localhost:5000
# EXPLORER_URL="https://chain.so/tx/ZEC/<txid>"
EXPLORER_URL="https://chain.so/tx/ZECTEST/<txid>"
# 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

View File

@ -147,6 +147,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
type="text"
value={title}
onChange={this.handleInputChange}
maxLength={200}
/>
</Form.Item>
@ -161,6 +162,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
value={brief}
onChange={this.handleInputChange}
rows={3}
maxLength={200}
/>
</Form.Item>
@ -196,6 +198,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
value={target}
onChange={this.handleInputChange}
addonAfter="ZEC"
maxLength={20}
/>
</Form.Item>
</Form>

View File

@ -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<Props, State> {
}
render() {
const errors = getCreateErrors(this.state, true);
return (
<Form layout="vertical" style={{ maxWidth: 980, margin: '0 auto' }}>
<MarkdownEditor
@ -29,6 +32,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
initialMarkdown={this.state.content}
minHeight={200}
/>
{errors.content && <Alert type="error" message={errors.content} showIcon />}
</Form>
);
}

View File

@ -138,6 +138,10 @@
font-size: 0.8rem;
opacity: 0.3;
animation: draft-notification-popup 120ms ease 1;
&.is-error {
color: @error-color;
}
}
&-loading {

View File

@ -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<Props, State> {
}
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<Props, State> {
)}
</div>
)}
{isSavingDraft && (
{isSavingDraft ? (
<div className="CreateFlow-draftNotification">Saving draft...</div>
) : (
saveDraftError && (
<div className="CreateFlow-draftNotification is-error">
Failed to save draft!
<br />
{saveDraftError}
</div>
)
)}
<SubmitWarningModal
proposal={this.props.form}
@ -326,13 +335,12 @@ class CreateFlow extends React.Component<Props, State> {
}
const withConnect = connect<StateProps, DispatchProps, {}, AppState>(
(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,
},

View File

@ -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;
}

View File

@ -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