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/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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -138,6 +138,10 @@
|
|||
font-size: 0.8rem;
|
||||
opacity: 0.3;
|
||||
animation: draft-notification-popup 120ms ease 1;
|
||||
|
||||
&.is-error {
|
||||
color: @error-color;
|
||||
}
|
||||
}
|
||||
|
||||
&-loading {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue