Merge branch 'develop' into batch-emails
This commit is contained in:
commit
badb9fc23d
|
@ -2,14 +2,14 @@
|
|||
"""The app module, containing the app factory function."""
|
||||
import sentry_sdk
|
||||
from animal_case import animalify
|
||||
from flask import Flask, Response, jsonify
|
||||
from flask import Flask, Response, jsonify, request
|
||||
from flask_cors import CORS
|
||||
from flask_security import SQLAlchemyUserDatastore
|
||||
from flask_sslify import SSLify
|
||||
from sentry_sdk.integrations.flask import FlaskIntegration
|
||||
|
||||
from grant import commands, proposal, user, comment, milestone, admin, email, blockchain, task, rfp
|
||||
from grant.extensions import bcrypt, migrate, db, ma, security
|
||||
from grant.extensions import bcrypt, migrate, db, ma, security, limiter
|
||||
from grant.settings import SENTRY_RELEASE, ENV
|
||||
from grant.utils.auth import AuthException, handle_auth_error, get_authed_user
|
||||
from grant.utils.exceptions import ValidationException
|
||||
|
@ -48,7 +48,12 @@ def create_app(config_objects=["grant.settings"]):
|
|||
return jsonify({"message": error_message}), err.code, headers
|
||||
else:
|
||||
return jsonify({"message": error_message}), err.code
|
||||
|
||||
|
||||
@app.errorhandler(429)
|
||||
def handle_limit_error(err):
|
||||
print(f'Rate limited request to {request.method} {request.path} from ip {request.remote_addr}')
|
||||
return jsonify({"message": "You’ve done that too many times, please wait and try again later"}), 429
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_exception(err):
|
||||
return jsonify({"message": "Something went wrong"}), 500
|
||||
|
@ -86,6 +91,7 @@ def register_extensions(app):
|
|||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
ma.init_app(app)
|
||||
limiter.init_app(app)
|
||||
user_datastore = SQLAlchemyUserDatastore(db, user.models.User, user.models.Role)
|
||||
security.init_app(app, datastore=user_datastore, register_blueprint=False)
|
||||
|
||||
|
|
|
@ -5,9 +5,12 @@ from flask_marshmallow import Marshmallow
|
|||
from flask_migrate import Migrate
|
||||
from flask_security import Security
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
|
||||
bcrypt = Bcrypt()
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
ma = Marshmallow()
|
||||
security = Security()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
|
|
@ -5,6 +5,7 @@ from flask import Blueprint, g, request
|
|||
from marshmallow import fields, validate
|
||||
from sqlalchemy import or_
|
||||
|
||||
from grant.extensions import limiter
|
||||
from grant.comment.models import Comment, comment_schema, comments_schema
|
||||
from grant.email.send import send_email, EmailSender
|
||||
from grant.milestone.models import Milestone
|
||||
|
@ -94,6 +95,7 @@ def report_proposal_comment(proposal_id, comment_id):
|
|||
|
||||
|
||||
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
|
||||
@limiter.limit("30/hour;2/minute")
|
||||
@requires_email_verified_auth
|
||||
@body({
|
||||
"comment": fields.Str(required=True),
|
||||
|
@ -174,6 +176,7 @@ def get_proposals(page, filters, search, sort):
|
|||
|
||||
|
||||
@blueprint.route("/drafts", methods=["POST"])
|
||||
@limiter.limit("10/hour;3/minute")
|
||||
@requires_email_verified_auth
|
||||
@body({
|
||||
"rfpId": fields.Int(required=False, missing=None)
|
||||
|
@ -351,6 +354,7 @@ def get_proposal_update(proposal_id, update_id):
|
|||
|
||||
|
||||
@blueprint.route("/<proposal_id>/updates", methods=["POST"])
|
||||
@limiter.limit("5/day;1/minute")
|
||||
@requires_team_member_auth
|
||||
@body({
|
||||
"title": fields.Str(required=True),
|
||||
|
@ -382,11 +386,19 @@ def post_proposal_update(proposal_id, title, content):
|
|||
|
||||
|
||||
@blueprint.route("/<proposal_id>/invite", methods=["POST"])
|
||||
@limiter.limit("30/day;10/minute")
|
||||
@requires_team_member_auth
|
||||
@body({
|
||||
"address": fields.Str(required=True),
|
||||
})
|
||||
def post_proposal_team_invite(proposal_id, address):
|
||||
existing_invite = ProposalTeamInvite.query.filter_by(
|
||||
proposal_id=proposal_id,
|
||||
address=address
|
||||
).first()
|
||||
if existing_invite:
|
||||
return {"message": f"You've already invited {address}"}, 400
|
||||
|
||||
invite = ProposalTeamInvite(
|
||||
proposal_id=proposal_id,
|
||||
address=address
|
||||
|
@ -474,6 +486,7 @@ def get_proposal_contribution(proposal_id, contribution_id):
|
|||
|
||||
|
||||
@blueprint.route("/<proposal_id>/contributions", methods=["POST"])
|
||||
@limiter.limit("30/day;10/hour;2/minute")
|
||||
# TODO add gaurd (minimum, maximum)
|
||||
@body({
|
||||
"amount": fields.Str(required=True),
|
||||
|
|
|
@ -3,6 +3,7 @@ from flask import Blueprint, g
|
|||
from marshmallow import fields
|
||||
|
||||
import grant.utils.auth as auth
|
||||
from grant.extensions import limiter
|
||||
from grant.comment.models import Comment, user_comments_schema
|
||||
from grant.email.models import EmailRecovery
|
||||
from grant.parser import query, body
|
||||
|
@ -89,6 +90,7 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending,
|
|||
|
||||
|
||||
@blueprint.route("/", methods=["POST"])
|
||||
@limiter.limit("30/day;5/minute")
|
||||
@body({
|
||||
# TODO guard all (valid, minimum, maximum)
|
||||
"emailAddress": fields.Str(required=True),
|
||||
|
@ -207,6 +209,7 @@ def verify_user_social(service, code):
|
|||
|
||||
|
||||
@blueprint.route("/recover", methods=["POST"])
|
||||
@limiter.limit("10/day;2/minute")
|
||||
@body({
|
||||
"email": fields.Str(required=True)
|
||||
})
|
||||
|
@ -239,6 +242,7 @@ def recover_email(code, password):
|
|||
|
||||
|
||||
@blueprint.route("/avatar", methods=["POST"])
|
||||
@limiter.limit("20/day;3/minute")
|
||||
@auth.requires_auth
|
||||
@body({
|
||||
"mimetype": fields.Str(required=True)
|
||||
|
|
|
@ -75,4 +75,7 @@ webargs==5.1.2
|
|||
pyotp==2.2.7
|
||||
|
||||
# JSON formatting
|
||||
animal_case==0.4.1
|
||||
animal_case==0.4.1
|
||||
|
||||
# Rate limiting
|
||||
Flask-Limiter==1.0.1
|
|
@ -8,6 +8,7 @@ from grant.task.jobs import ProposalReminder
|
|||
from grant.user.models import User, SocialMedia, db, Avatar
|
||||
from grant.settings import PROPOSAL_STAKING_AMOUNT
|
||||
from grant.utils.enums import ProposalStatus
|
||||
from grant.extensions import limiter
|
||||
|
||||
from .test_data import test_user, test_other_user, test_proposal, mock_blockchain_api_requests
|
||||
|
||||
|
@ -17,6 +18,7 @@ class BaseTestConfig(TestCase):
|
|||
def create_app(self):
|
||||
app = create_app(['grant.settings', 'tests.settings'])
|
||||
app.config.from_object('tests.settings')
|
||||
limiter.enabled = False
|
||||
return app
|
||||
|
||||
def setUp(self):
|
||||
|
|
|
@ -53,7 +53,15 @@ class AuthRoute extends React.Component<Props> {
|
|||
if (onlyLoggedOut) {
|
||||
newLocation = authForwardLocation || { ...location, pathname: '/profile' };
|
||||
}
|
||||
return <Redirect to={{ ...newLocation }} />;
|
||||
return (
|
||||
<>
|
||||
<noscript className="noScript is-block">
|
||||
This page requires you to login before you can access it, but you have
|
||||
Javascript disabled. Please enable Javascript and login to continue.
|
||||
</noscript>
|
||||
<Redirect to={{ ...newLocation }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
private setAuthForward = () => {
|
||||
|
|
|
@ -139,16 +139,24 @@ export default class ContributionModal extends React.Component<Props, State> {
|
|||
if (error) {
|
||||
okText = 'Done';
|
||||
onOk = handleClose;
|
||||
content = (
|
||||
<Result
|
||||
type="error"
|
||||
title="Something went wrong"
|
||||
description={`
|
||||
We were unable to get your contribution started. Please check back
|
||||
soon, we're working to fix the problem as soon as possible.
|
||||
`}
|
||||
/>
|
||||
);
|
||||
// This should probably key on non-display text, but oh well.
|
||||
let title;
|
||||
let description;
|
||||
if (error.includes('too many times')) {
|
||||
title = 'Take it easy!';
|
||||
description = `
|
||||
We appreciate your enthusiasm, but you've made too many
|
||||
contributions too fast. Please wait for your other contributions,
|
||||
and try again later.
|
||||
`;
|
||||
} else {
|
||||
title = 'Something went wrong';
|
||||
description = `
|
||||
We were unable to get your contribution started. Please check back
|
||||
soon, we're working to fix the problem as soon as possible.
|
||||
`;
|
||||
}
|
||||
content = <Result type="error" title={title} description={description} />;
|
||||
} else {
|
||||
okText = 'I’ve sent it';
|
||||
onOk = this.confirmSend;
|
||||
|
|
|
@ -51,9 +51,10 @@ class CreateFlowTeam extends React.Component<Props, State> {
|
|||
|
||||
render() {
|
||||
const { team, invites, address } = this.state;
|
||||
const inviteError = address && !isValidEmail(address) &&
|
||||
'That doesn’t look like a valid email address';
|
||||
const inviteDisabled = !!inviteError || !address;
|
||||
const inviteError =
|
||||
address && !isValidEmail(address) && 'That doesn’t look like a valid email address';
|
||||
const maxedOut = invites.length >= MAX_TEAM_SIZE - 1;
|
||||
const inviteDisabled = !!inviteError || !address || maxedOut;
|
||||
const pendingInvites = invites.filter(inv => inv.accepted === null);
|
||||
|
||||
return (
|
||||
|
@ -79,39 +80,39 @@ class CreateFlowTeam extends React.Component<Props, State> {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
{team.length < MAX_TEAM_SIZE && (
|
||||
<div className="TeamForm-add">
|
||||
<h3 className="TeamForm-add-title">Add a team member</h3>
|
||||
<Form className="TeamForm-add-form" onSubmit={this.handleAddSubmit}>
|
||||
<Form.Item
|
||||
className="TeamForm-add-form-field"
|
||||
validateStatus={inviteError ? 'error' : undefined}
|
||||
help={
|
||||
inviteError ||
|
||||
'They will be notified and will have to accept the invitation before being added'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
className="TeamForm-add-form-field-input"
|
||||
placeholder="Email address"
|
||||
size="large"
|
||||
value={address}
|
||||
onChange={this.handleChangeInviteAddress}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
className="TeamForm-add-form-submit"
|
||||
type="primary"
|
||||
disabled={inviteDisabled}
|
||||
htmlType="submit"
|
||||
icon="user-add"
|
||||
<div className="TeamForm-add">
|
||||
<h3 className="TeamForm-add-title">Add a team member</h3>
|
||||
<Form className="TeamForm-add-form" onSubmit={this.handleAddSubmit}>
|
||||
<Form.Item
|
||||
className="TeamForm-add-form-field"
|
||||
validateStatus={inviteError ? 'error' : undefined}
|
||||
help={
|
||||
inviteError ||
|
||||
(maxedOut && 'You’ve invited the maximum number of teammates') ||
|
||||
'They will be notified and will have to accept the invitation before being added'
|
||||
}
|
||||
>
|
||||
<Input
|
||||
className="TeamForm-add-form-field-input"
|
||||
placeholder="Email address"
|
||||
size="large"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
value={address}
|
||||
onChange={this.handleChangeInviteAddress}
|
||||
disabled={maxedOut}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
className="TeamForm-add-form-submit"
|
||||
type="primary"
|
||||
disabled={inviteDisabled}
|
||||
htmlType="submit"
|
||||
icon="user-add"
|
||||
size="large"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -133,7 +134,7 @@ class CreateFlowTeam extends React.Component<Props, State> {
|
|||
})
|
||||
.catch((err: Error) => {
|
||||
console.error('Failed to send invite', err);
|
||||
message.error('Failed to send invite', 3);
|
||||
message.error(err.message, 3);
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -146,7 +147,7 @@ class CreateFlowTeam extends React.Component<Props, State> {
|
|||
})
|
||||
.catch((err: Error) => {
|
||||
console.error('Failed to remove invite', err);
|
||||
message.error('Failed to remove invite', 3);
|
||||
message.error(err.message, 3);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
@ -67,10 +67,10 @@ class DraftList extends React.Component<Props, State> {
|
|||
this.setState({ deletingId: null });
|
||||
}
|
||||
if (deleteDraftError && prevProps.deleteDraftError !== deleteDraftError) {
|
||||
message.error('Failed to delete draft', 3);
|
||||
message.error(deleteDraftError, 3);
|
||||
}
|
||||
if (createDraftError && prevProps.createDraftError !== createDraftError) {
|
||||
message.error('Failed to create draft', 3);
|
||||
message.error(createDraftError, 3);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -29,6 +29,12 @@ export default class Template extends React.PureComponent<Props> {
|
|||
);
|
||||
return (
|
||||
<BasicHead title={title}>
|
||||
{!isHeaderTransparent && (
|
||||
<noscript className="noScript is-banner">
|
||||
It looks like you have Javascript disabled. You may experience issues with
|
||||
interactive parts of the site.
|
||||
</noscript>
|
||||
)}
|
||||
<div className={className}>
|
||||
<Header isTransparent={isHeaderTransparent} />
|
||||
<Layout.Content className="Template-content">
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
import React from 'react';
|
||||
import AuthFlow from 'components/AuthFlow';
|
||||
|
||||
const SignInPage = () => <AuthFlow />;
|
||||
const SignInPage = () => (
|
||||
<>
|
||||
<noscript className="noScript is-block">
|
||||
Sign in and account creation require Javascript. Please enable it to continue.
|
||||
</noscript>
|
||||
<AuthFlow />
|
||||
</>
|
||||
);
|
||||
|
||||
export default SignInPage;
|
||||
|
|
|
@ -10,7 +10,14 @@ class CreatePage extends React.Component<Props> {
|
|||
const { location } = this.props;
|
||||
const parsed = parse(location.search);
|
||||
const rfpId = parsed.rfp ? parseInt(parsed.rfp, 10) : undefined;
|
||||
return <DraftList createIfNone createWithRfpId={rfpId} />;
|
||||
return (
|
||||
<>
|
||||
<noscript className="noScript is-block">
|
||||
Proposal creation requires Javascript. You’ll need to enable it to continue.
|
||||
</noscript>
|
||||
<DraftList createIfNone createWithRfpId={rfpId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
@import 'variables.less';
|
||||
|
||||
.noScript {
|
||||
display: block;
|
||||
|
||||
&.is-banner {
|
||||
padding: 0.25rem 1rem;
|
||||
text-align: center;
|
||||
background: @warning-color;
|
||||
color: #FFF;
|
||||
}
|
||||
|
||||
&.is-block {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
max-width: 420px;
|
||||
margin: 2rem auto;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid @warning-color;
|
||||
background: rgba(@warning-color, 0.05);
|
||||
border-radius: 2px;
|
||||
|
||||
&:before {
|
||||
content: '!';
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.8rem;
|
||||
width: 2.6rem;
|
||||
height: 2.6rem;
|
||||
margin-right: 1rem;
|
||||
border: 2px solid @warning-color;
|
||||
color: @warning-color;
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,3 +9,4 @@
|
|||
@import 'react-mde-overrides.less';
|
||||
@import 'antd-overrides.less';
|
||||
@import 'html.less';
|
||||
@import 'noscript.less';
|
Loading…
Reference in New Issue