Stricter validation, truncate before db entry, env var proposal target limit
This commit is contained in:
parent
ba704f5f5c
commit
adc2fd4d63
|
@ -32,5 +32,8 @@ BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca"
|
||||||
# EXPLORER_URL="https://chain.so/tx/ZEC/<txid>"
|
# EXPLORER_URL="https://chain.so/tx/ZEC/<txid>"
|
||||||
EXPLORER_URL="https://chain.so/tx/ZECTEST/<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
|
PROPOSAL_STAKING_AMOUNT=0.025
|
||||||
|
|
||||||
|
# Maximum amount for a proposal target, keep in sync with frontend .env
|
||||||
|
PROPOSAL_TARGET_MAX=10000
|
||||||
|
|
|
@ -70,21 +70,16 @@ class Milestone(db.Model):
|
||||||
[db.session.delete(x) for x in proposal.milestones]
|
[db.session.delete(x) for x in proposal.milestones]
|
||||||
for i, milestone_data in enumerate(milestones_data):
|
for i, milestone_data in enumerate(milestones_data):
|
||||||
m = Milestone(
|
m = Milestone(
|
||||||
title=milestone_data["title"],
|
title=milestone_data["title"][:255],
|
||||||
content=milestone_data["content"],
|
content=milestone_data["content"][:255],
|
||||||
date_estimated=datetime.datetime.fromtimestamp(milestone_data["date_estimated"]),
|
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"],
|
immediate_payout=milestone_data["immediate_payout"],
|
||||||
proposal_id=proposal.id,
|
proposal_id=proposal.id,
|
||||||
index=i
|
index=i
|
||||||
)
|
)
|
||||||
db.session.add(m)
|
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):
|
def request_payout(self, user_id: int):
|
||||||
if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]:
|
if self.stage not in [MilestoneStage.IDLE, MilestoneStage.REJECTED]:
|
||||||
raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage')
|
raise MilestoneException(f'Cannot request payout for milestone at {self.stage} stage')
|
||||||
|
|
|
@ -11,7 +11,7 @@ from flask import current_app
|
||||||
from grant.comment.models import Comment
|
from grant.comment.models import Comment
|
||||||
from grant.email.send import send_email
|
from grant.email.send import send_email
|
||||||
from grant.extensions import ma, db
|
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.task.jobs import ContributionExpired
|
||||||
from grant.utils.enums import (
|
from grant.utils.enums import (
|
||||||
ProposalStatus,
|
ProposalStatus,
|
||||||
|
@ -275,12 +275,11 @@ class Proposal(db.Model):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def simple_validate(proposal):
|
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')
|
stage = proposal.get('stage')
|
||||||
category = proposal.get('category')
|
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):
|
if stage and not ProposalStage.includes(stage):
|
||||||
raise ValidationException("Proposal stage {} is not a valid stage".format(stage))
|
raise ValidationException("Proposal stage {} is not a valid stage".format(stage))
|
||||||
if category and not Category.includes(category):
|
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")
|
raise ValidationException("Only the first milestone can have an immediate payout")
|
||||||
|
|
||||||
if len(milestone.title) > 60:
|
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:
|
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)
|
payout_total += float(milestone.payout_percent)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
present = datetime.datetime.today().replace(day=1)
|
present = datetime.datetime.today().replace(day=1)
|
||||||
if present > milestone.date_estimated:
|
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:
|
except Exception as e:
|
||||||
current_app.logger.warn(
|
current_app.logger.warn(
|
||||||
f"Unexpected validation error - client prohibits {e}"
|
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:
|
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):
|
def validate_publishable(self):
|
||||||
self.validate_publishable_milestones()
|
self.validate_publishable_milestones()
|
||||||
|
|
||||||
# Require certain fields
|
# Require certain fields
|
||||||
|
|
||||||
required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address']
|
required_fields = ['title', 'content', 'brief', 'category', 'target', 'payout_address']
|
||||||
for field in required_fields:
|
for field in required_fields:
|
||||||
if not hasattr(self, field):
|
if not hasattr(self, field):
|
||||||
raise ValidationException("Proposal must have a {}".format(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
|
# Check with node that the address is kosher
|
||||||
try:
|
try:
|
||||||
res = blockchain_get('/validate/address', {'address': self.payout_address})
|
res = blockchain_get('/validate/address', {'address': self.payout_address})
|
||||||
|
@ -380,12 +398,12 @@ class Proposal(db.Model):
|
||||||
payout_address: str = '',
|
payout_address: str = '',
|
||||||
deadline_duration: int = 5184000 # 60 days
|
deadline_duration: int = 5184000 # 60 days
|
||||||
):
|
):
|
||||||
self.title = title
|
self.title = title[:255]
|
||||||
self.brief = brief
|
self.brief = brief[:255]
|
||||||
self.category = category
|
self.category = category
|
||||||
self.content = content
|
self.content = content[:300000]
|
||||||
self.target = target if target != '' else None
|
self.target = target[:255] if target != '' else None
|
||||||
self.payout_address = payout_address
|
self.payout_address = payout_address[:255]
|
||||||
self.deadline_duration = deadline_duration
|
self.deadline_duration = deadline_duration
|
||||||
Proposal.simple_validate(vars(self))
|
Proposal.simple_validate(vars(self))
|
||||||
|
|
||||||
|
|
|
@ -218,6 +218,7 @@ def get_proposal_drafts():
|
||||||
@blueprint.route("/<proposal_id>", methods=["PUT"])
|
@blueprint.route("/<proposal_id>", methods=["PUT"])
|
||||||
@requires_team_member_auth
|
@requires_team_member_auth
|
||||||
@body({
|
@body({
|
||||||
|
# Length checks are to prevent database errors, not actual user limits imposed
|
||||||
"title": fields.Str(required=True),
|
"title": fields.Str(required=True),
|
||||||
"brief": fields.Str(required=True),
|
"brief": fields.Str(required=True),
|
||||||
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list() + [''])),
|
"category": fields.Str(required=True, validate=validate.OneOf(choices=Category.list() + [''])),
|
||||||
|
@ -226,7 +227,7 @@ def get_proposal_drafts():
|
||||||
"payoutAddress": fields.Str(required=True),
|
"payoutAddress": fields.Str(required=True),
|
||||||
"deadlineDuration": fields.Int(required=True),
|
"deadlineDuration": fields.Int(required=True),
|
||||||
"milestones": fields.List(fields.Dict(), 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):
|
def update_proposal(milestones, proposal_id, rfp_opt_in, **kwargs):
|
||||||
# Update the base proposal fields
|
# Update the base proposal fields
|
||||||
|
|
|
@ -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>")
|
EXPLORER_URL = env.str("EXPLORER_URL", default="https://chain.so/tx/ZECTEST/<txid>")
|
||||||
|
|
||||||
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))
|
PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT"))
|
||||||
|
PROPOSAL_TARGET_MAX = Decimal(env.str("PROPOSAL_TARGET_MAX"))
|
||||||
|
|
||||||
UI = {
|
UI = {
|
||||||
'NAME': 'ZF Grants',
|
'NAME': 'ZF Grants',
|
||||||
|
|
|
@ -16,7 +16,7 @@ BACKEND_URL=http://localhost:5000
|
||||||
# EXPLORER_URL="https://chain.so/tx/ZEC/<txid>"
|
# EXPLORER_URL="https://chain.so/tx/ZEC/<txid>"
|
||||||
EXPLORER_URL="https://chain.so/tx/ZECTEST/<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
|
PROPOSAL_STAKING_AMOUNT=0.025
|
||||||
|
|
||||||
# Normally production runs with SSL, this disables that
|
# Normally production runs with SSL, this disables that
|
||||||
|
@ -24,3 +24,6 @@ DISABLE_SSL=true
|
||||||
|
|
||||||
# Uncomment if running on testnet
|
# Uncomment if running on testnet
|
||||||
# TESTNET=true
|
# TESTNET=true
|
||||||
|
|
||||||
|
# Maximum amount for a proposal target, keep in sync with backend .env
|
||||||
|
PROPOSAL_TARGET_MAX=10000
|
||||||
|
|
|
@ -147,6 +147,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={this.handleInputChange}
|
onChange={this.handleInputChange}
|
||||||
|
maxLength={200}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
@ -161,6 +162,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
|
||||||
value={brief}
|
value={brief}
|
||||||
onChange={this.handleInputChange}
|
onChange={this.handleInputChange}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
maxLength={200}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
@ -196,6 +198,7 @@ class CreateFlowBasics extends React.Component<Props, State> {
|
||||||
value={target}
|
value={target}
|
||||||
onChange={this.handleInputChange}
|
onChange={this.handleInputChange}
|
||||||
addonAfter="ZEC"
|
addonAfter="ZEC"
|
||||||
|
maxLength={20}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Form } from 'antd';
|
import { Form, Alert } from 'antd';
|
||||||
import MarkdownEditor from 'components/MarkdownEditor';
|
import MarkdownEditor from 'components/MarkdownEditor';
|
||||||
import { ProposalDraft } from 'types';
|
import { ProposalDraft } from 'types';
|
||||||
|
import { getCreateErrors } from 'modules/create/utils';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
content: string;
|
content: string;
|
||||||
|
@ -22,6 +23,8 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const errors = getCreateErrors(this.state, true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form layout="vertical" style={{ maxWidth: 980, margin: '0 auto' }}>
|
<Form layout="vertical" style={{ maxWidth: 980, margin: '0 auto' }}>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
|
@ -29,6 +32,7 @@ export default class CreateFlowTeam extends React.Component<Props, State> {
|
||||||
initialMarkdown={this.state.content}
|
initialMarkdown={this.state.content}
|
||||||
minHeight={200}
|
minHeight={200}
|
||||||
/>
|
/>
|
||||||
|
{errors.content && <Alert type="error" message={errors.content} showIcon />}
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -138,6 +138,10 @@
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
animation: draft-notification-popup 120ms ease 1;
|
animation: draft-notification-popup 120ms ease 1;
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
color: @error-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&-loading {
|
&-loading {
|
||||||
|
|
|
@ -103,6 +103,7 @@ interface StateProps {
|
||||||
form: AppState['create']['form'];
|
form: AppState['create']['form'];
|
||||||
isSavingDraft: AppState['create']['isSavingDraft'];
|
isSavingDraft: AppState['create']['isSavingDraft'];
|
||||||
hasSavedDraft: AppState['create']['hasSavedDraft'];
|
hasSavedDraft: AppState['create']['hasSavedDraft'];
|
||||||
|
saveDraftError: AppState['create']['saveDraftError'];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DispatchProps {
|
interface DispatchProps {
|
||||||
|
@ -149,7 +150,7 @@ class CreateFlow extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { isSavingDraft } = this.props;
|
const { isSavingDraft, saveDraftError } = this.props;
|
||||||
const { step, isPreviewing, isSubmitting, isShowingSubmitWarning } = this.state;
|
const { step, isPreviewing, isSubmitting, isShowingSubmitWarning } = this.state;
|
||||||
|
|
||||||
const info = STEP_INFO[step];
|
const info = STEP_INFO[step];
|
||||||
|
@ -238,8 +239,16 @@ class CreateFlow extends React.Component<Props, State> {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isSavingDraft && (
|
{isSavingDraft ? (
|
||||||
<div className="CreateFlow-draftNotification">Saving draft...</div>
|
<div className="CreateFlow-draftNotification">Saving draft...</div>
|
||||||
|
) : (
|
||||||
|
saveDraftError && (
|
||||||
|
<div className="CreateFlow-draftNotification is-error">
|
||||||
|
Failed to save draft!
|
||||||
|
<br />
|
||||||
|
{saveDraftError}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<SubmitWarningModal
|
<SubmitWarningModal
|
||||||
proposal={this.props.form}
|
proposal={this.props.form}
|
||||||
|
@ -326,13 +335,12 @@ class CreateFlow extends React.Component<Props, State> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const withConnect = connect<StateProps, DispatchProps, {}, AppState>(
|
const withConnect = connect<StateProps, DispatchProps, {}, AppState>(
|
||||||
(state: AppState) => {
|
(state: AppState) => ({
|
||||||
return {
|
form: state.create.form,
|
||||||
form: state.create.form,
|
isSavingDraft: state.create.isSavingDraft,
|
||||||
isSavingDraft: state.create.isSavingDraft,
|
hasSavedDraft: state.create.hasSavedDraft,
|
||||||
hasSavedDraft: state.create.hasSavedDraft,
|
saveDraftError: state.create.saveDraftError,
|
||||||
};
|
}),
|
||||||
},
|
|
||||||
{
|
{
|
||||||
updateForm: createActions.updateForm,
|
updateForm: createActions.updateForm,
|
||||||
},
|
},
|
||||||
|
|
|
@ -13,8 +13,6 @@ import {
|
||||||
PROPOSAL_DETAIL_INITIAL_STATE,
|
PROPOSAL_DETAIL_INITIAL_STATE,
|
||||||
} from 'modules/proposals/reducers';
|
} from 'modules/proposals/reducers';
|
||||||
|
|
||||||
export const TARGET_ZEC_LIMIT = 1000;
|
|
||||||
|
|
||||||
interface CreateFormErrors {
|
interface CreateFormErrors {
|
||||||
rfpOptIn?: string;
|
rfpOptIn?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
@ -57,7 +55,17 @@ export function getCreateErrors(
|
||||||
skipRequired?: boolean,
|
skipRequired?: boolean,
|
||||||
): CreateFormErrors {
|
): CreateFormErrors {
|
||||||
const errors: 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
|
// Required fields with no extra validation
|
||||||
if (!skipRequired) {
|
if (!skipRequired) {
|
||||||
|
@ -90,10 +98,16 @@ export function getCreateErrors(
|
||||||
errors.brief = 'Brief can only be 140 characters maximum';
|
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
|
// Amount to raise
|
||||||
const targetFloat = target ? parseFloat(target) : 0;
|
const targetFloat = target ? parseFloat(target) : 0;
|
||||||
if (target && !Number.isNaN(targetFloat)) {
|
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) {
|
if (targetErr) {
|
||||||
errors.target = targetErr;
|
errors.target = targetErr;
|
||||||
}
|
}
|
||||||
|
|
|
@ -61,6 +61,7 @@ module.exports = () => {
|
||||||
SENTRY_DSN: process.env.SENTRY_DSN || null,
|
SENTRY_DSN: process.env.SENTRY_DSN || null,
|
||||||
SENTRY_RELEASE: process.env.SENTRY_RELEASE || undefined,
|
SENTRY_RELEASE: process.env.SENTRY_RELEASE || undefined,
|
||||||
TESTNET: process.env.TESTNET || false,
|
TESTNET: process.env.TESTNET || false,
|
||||||
|
PROPOSAL_TARGET_MAX: process.env.PROPOSAL_TARGET_MAX || '10000',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stringify all values so we can feed into Webpack DefinePlugin
|
// Stringify all values so we can feed into Webpack DefinePlugin
|
||||||
|
|
Loading…
Reference in New Issue