Merge pull request #218 from grant-project/avatar-upload-basics

Avatar upload and download
This commit is contained in:
William O'Beirne 2018-11-19 19:22:31 -05:00 committed by GitHub
commit 0496b58130
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 445 additions and 109 deletions

View File

@ -8,4 +8,6 @@ SECRET_KEY="not-so-secret"
SENDGRID_API_KEY="optional, but emails won't send without it"
# for ropsten use the following
# ETHEREUM_ENDPOINT_URI = "https://ropsten.infura.io/API_KEY"
ETHEREUM_ENDPOINT_URI = "http://localhost:8545"
ETHEREUM_ENDPOINT_URI = "http://localhost:8545"
UPLOAD_DIRECTORY = "/tmp"
UPLOAD_URL = "http://localhost:5000" # for constructing download url

View File

@ -27,3 +27,6 @@ SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="")
SENDGRID_DEFAULT_FROM = "noreply@grant.io"
ETHEREUM_PROVIDER = "http"
ETHEREUM_ENDPOINT_URI = env.str("ETHEREUM_ENDPOINT_URI")
UPLOAD_DIRECTORY = env.str("UPLOAD_DIRECTORY")
UPLOAD_URL = env.str("UPLOAD_URL")
MAX_CONTENT_LENGTH = 5 * 1024 * 1024 # 5MB (limits file uploads, raises RequestEntityTooLarge)

View File

@ -1,8 +1,10 @@
from flask import Blueprint, g
from flask import Blueprint, g, request
from flask_yoloapi import endpoint, parameter
from grant.proposal.models import Proposal, proposal_team
from grant.utils.auth import requires_sm, requires_same_user_auth, verify_signed_auth, BadSignatureException
from grant.utils.upload import save_avatar, send_upload, remove_avatar
from grant.settings import UPLOAD_URL
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
@ -69,11 +71,11 @@ def create_user(
sig_address = verify_signed_auth(signed_message, raw_typed_data)
if sig_address.lower() != account_address.lower():
return {
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
sig_address=sig_address,
account_address=account_address
)
}, 400
)
}, 400
except BadSignatureException:
return {"message": "Invalid message signature"}, 400
@ -103,17 +105,49 @@ def auth_user(account_address, signed_message, raw_typed_data):
sig_address = verify_signed_auth(signed_message, raw_typed_data)
if sig_address.lower() != account_address.lower():
return {
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
sig_address=sig_address,
account_address=account_address
)
}, 400
)
}, 400
except BadSignatureException:
return {"message": "Invalid message signature"}, 400
return user_schema.dump(existing_user)
@blueprint.route("/avatar", methods=["POST"])
@requires_sm
@endpoint.api()
def upload_avatar():
user = g.current_user
if 'file' not in request.files:
return {"message": "No file in post"}, 400
file = request.files['file']
if file.filename == '':
return {"message": "No selected file"}, 400
try:
filename = save_avatar(file, user.id)
return {"url": "{0}/api/v1/users/avatar/{1}".format(UPLOAD_URL, filename)}
except Exception as e:
return {"message": str(e)}, 400
@blueprint.route("/avatar/<filename>", methods=["GET"])
def get_avatar(filename):
return send_upload(filename)
@blueprint.route("/avatar", methods=["DELETE"])
@requires_sm
@endpoint.api(
parameter('url', type=str, required=True)
)
def delete_avatar(url):
user = g.current_user
remove_avatar(url, user.id)
@blueprint.route("/<user_identity>", methods=["PUT"])
@requires_sm
@requires_same_user_auth
@ -140,6 +174,7 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
else:
SocialMedia.query.filter_by(user_id=user.id).delete()
old_avatar = Avatar.query.filter_by(user_id=user.id).first()
if avatar is not None:
Avatar.query.filter_by(user_id=user.id).delete()
avatar_link = avatar.get('link')
@ -149,6 +184,11 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
else:
Avatar.query.filter_by(user_id=user.id).delete()
old_avatar_url = old_avatar and old_avatar.image_url
new_avatar_url = avatar and avatar['link']
if old_avatar_url and old_avatar_url != new_avatar_url:
remove_avatar(old_avatar_url, user.id)
db.session.commit()
result = user_schema.dump(user)
return result

View File

@ -0,0 +1,54 @@
import os
import re
from hashlib import md5
from werkzeug.utils import secure_filename
from flask import send_from_directory
from grant.settings import UPLOAD_DIRECTORY
IMAGE_MIME_TYPES = set(['image/png', 'image/jpg', 'image/gif'])
AVATAR_MAX_SIZE = 2 * 1024 * 1024 # 2MB
class FileValidationException(Exception):
pass
def allowed_avatar_file(file):
if file.mimetype not in IMAGE_MIME_TYPES:
raise FileValidationException("Unacceptable file type: {0}".format(file.mimetype))
file.seek(0, os.SEEK_END)
size = file.tell()
file.seek(0)
if size > AVATAR_MAX_SIZE:
raise FileValidationException(
"File size is too large ({0}KB), max size is {1}KB".format(size / 1024, AVATAR_MAX_SIZE / 1024)
)
return True
def hash_file(file):
hasher = md5()
buf = file.read()
hasher.update(buf)
file.seek(0)
return hasher.hexdigest()
def save_avatar(file, user_id):
if file and allowed_avatar_file(file):
ext = file.mimetype.replace('image/', '')
filename = "{0}.{1}.{2}".format(user_id, hash_file(file), ext)
file.save(os.path.join(UPLOAD_DIRECTORY, filename))
return filename
def remove_avatar(url, user_id):
match = re.search(r'/api/v1/users/avatar/(\d+.\w+.\w+)', url)
if match:
filename = match.group(1)
if filename.startswith(str(user_id) + '.'):
os.remove(os.path.join(UPLOAD_DIRECTORY, filename))
def send_upload(filename):
return send_from_directory(UPLOAD_DIRECTORY, secure_filename(filename))

View File

@ -0,0 +1,74 @@
@small-query: ~'(max-width: 500px)';
.AvatarEdit {
&-avatar {
position: relative;
height: 10.5rem;
width: 10.5rem;
margin-right: 1.25rem;
align-self: start;
@media @small-query {
margin-bottom: 1rem;
}
&-img {
height: 100%;
width: 100%;
border-radius: 1rem;
}
&-change {
position: absolute;
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
color: #ffffff;
font-size: 1.2rem;
font-weight: 600;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
border-radius: 1rem;
border: none;
cursor: pointer;
&:hover,
&:hover:focus {
background: rgba(0, 0, 0, 0.6);
color: #ffffff;
border: none;
}
&:focus {
background: rgba(0, 0, 0, 0.4);
color: #ffffff;
border: none;
}
&-icon {
font-size: 2rem;
}
}
&-delete {
position: absolute;
top: 0.2rem;
right: 0.2rem;
cursor: pointer;
color: #ffffff;
background: transparent;
border: none;
&:hover,
&:hover:focus {
background: rgba(0, 0, 0, 0.4);
color: #ffffff;
border: none;
}
}
}
}

View File

@ -0,0 +1,176 @@
import React from 'react';
import axios from 'api/axios';
import { Upload, Icon, Modal, Button, Alert } from 'antd';
import Cropper from 'react-cropper';
import 'cropperjs/dist/cropper.css';
import { UploadFile } from 'antd/lib/upload/interface';
import { TeamMember } from 'types';
import { getBase64 } from 'utils/blob';
import UserAvatar from 'components/UserAvatar';
import './AvatarEdit.less';
const FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
const FILE_MAX_LOAD_MB = 10;
interface OwnProps {
user: TeamMember;
onDelete(): void;
onDone(url: string): void;
}
const initialState = {
isUploading: false,
showModal: false,
newAvatarUrl: '',
loadError: '',
uploadError: '',
};
type State = typeof initialState;
type Props = OwnProps;
export default class AvatarEdit extends React.PureComponent<Props, State> {
state = initialState;
cropperRef: React.RefObject<any>;
constructor(props: Props) {
super(props);
this.cropperRef = React.createRef();
}
render() {
const { newAvatarUrl, showModal, loadError, uploadError, isUploading } = this.state;
const {
user,
user: { avatarUrl },
} = this.props;
return (
<>
{' '}
<div className="AvatarEdit-avatar">
<UserAvatar className="AvatarEdit-avatar-img" user={user} />
<Upload
name="avatar"
showUploadList={false}
action={this.handleLoad}
beforeUpload={this.beforeLoad}
onChange={this.handleLoadChange}
>
<Button className="AvatarEdit-avatar-change">
<Icon
className="AvatarEdit-avatar-change-icon"
type={avatarUrl ? 'picture' : 'plus-circle'}
/>
<div>{avatarUrl ? 'Change photo' : 'Add photo'}</div>
</Button>
</Upload>
{avatarUrl && (
<Button
className="AvatarEdit-avatar-delete"
icon="delete"
shape="circle"
onClick={this.props.onDelete}
/>
)}
{loadError && (
<Alert message={loadError} type="error" style={{ margin: '0.5rem 0 0 0' }} />
)}
</div>
<Modal
title="Prepare your avatar"
visible={showModal}
footer={[
<Button key="back" onClick={this.handleClose}>
Cancel
</Button>,
<Button
key="submit"
type="primary"
loading={isUploading}
onClick={this.handleUpload}
>
Upload
</Button>,
]}
>
<Cropper
ref={this.cropperRef}
src={newAvatarUrl}
style={{ height: 300 }}
aspectRatio={1}
guides={false}
viewMode={1}
/>
{uploadError && (
<Alert
message={uploadError}
type="error"
style={{ margin: '0.5rem 0 0 0' }}
/>
)}
</Modal>
</>
);
}
private handleClose = () => {
this.setState({
isUploading: false,
showModal: false,
newAvatarUrl: '',
uploadError: '',
});
};
private handleLoadChange = (info: any) => {
if (info.file.status === 'done') {
getBase64(info.file.originFileObj, newAvatarUrl =>
this.setState({
newAvatarUrl,
}),
);
}
};
private beforeLoad = (file: UploadFile) => {
this.setState({ loadError: '' });
const isTypeOk = !!FILE_TYPES.find(t => t === file.type);
if (!isTypeOk) {
this.setState({ loadError: 'File must be a jpg, png or gif' });
}
const isSizeOk = file.size / 1024 / 1024 < FILE_MAX_LOAD_MB;
if (!isSizeOk) {
this.setState({
loadError: `File size must be less than ${FILE_MAX_LOAD_MB}MB`,
});
}
return isTypeOk && isSizeOk;
};
private handleLoad = () => {
this.setState({ showModal: true });
return Promise.resolve();
};
private handleUpload = () => {
this.cropperRef.current
.getCroppedCanvas({ width: 400, height: 400 })
.toBlob((blob: Blob) => {
const formData = new FormData();
formData.append('file', blob);
this.setState({ isUploading: true });
axios
.post('/api/v1/users/avatar', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
.then(res => {
this.props.onDone(res.data.url);
this.handleClose();
})
.catch(err => {
this.setState({ isUploading: false, uploadError: err.message });
});
});
};
}

View File

@ -7,12 +7,12 @@
bottom: 0;
left: 0;
background: rgba(0, 0, 0, 0.3);
z-index: 1000;
z-index: 900;
}
.ProfileEdit {
position: relative;
z-index: 1001;
z-index: 901;
display: flex;
align-items: center;
padding: 1rem;
@ -28,77 +28,6 @@
align-items: flex-start;
}
&-avatar {
position: relative;
height: 10.5rem;
width: 10.5rem;
margin-right: 1.25rem;
align-self: start;
@media @small-query {
margin-bottom: 1rem;
}
&-img {
height: 100%;
width: 100%;
border-radius: 1rem;
}
&-change {
position: absolute;
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
color: #ffffff;
font-size: 1.2rem;
font-weight: 600;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
border-radius: 1rem;
border: none;
cursor: pointer;
&:hover,
&:hover:focus {
background: rgba(0, 0, 0, 0.6);
color: #ffffff;
border: none;
}
&:focus {
background: rgba(0, 0, 0, 0.4);
color: #ffffff;
border: none;
}
&-icon {
font-size: 2rem;
}
}
&-delete {
position: absolute;
top: 0.2rem;
right: 0.2rem;
cursor: pointer;
color: #ffffff;
background: transparent;
border: none;
&:hover,
&:hover:focus {
background: rgba(0, 0, 0, 0.4);
color: #ffffff;
border: none;
}
}
}
&-info {
flex: 1;

View File

@ -1,11 +1,12 @@
import React from 'react';
import lodash from 'lodash';
import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd';
import axios from 'api/axios';
import { Input, Form, Col, Row, Button, Alert } from 'antd';
import { SOCIAL_INFO } from 'utils/social';
import { SOCIAL_TYPE, TeamMember } from 'types';
import { UserState } from 'modules/users/reducers';
import { getCreateTeamMemberError } from 'modules/create/utils';
import UserAvatar from 'components/UserAvatar';
import AvatarEdit from './AvatarEdit';
import './ProfileEdit.less';
interface Props {
@ -54,27 +55,12 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
return (
<>
<div className="ProfileEdit">
<div className="ProfileEdit-avatar">
<UserAvatar className="ProfileEdit-avatar-img" user={fields} />
<Button
className="ProfileEdit-avatar-change"
onClick={this.handleChangePhoto}
>
<Icon
className="ProfileEdit-avatar-change-icon"
type={fields.avatarUrl ? 'picture' : 'plus-circle'}
/>
<div>{fields.avatarUrl ? 'Change photo' : 'Add photo'}</div>
</Button>
{fields.avatarUrl && (
<Button
className="ProfileEdit-avatar-delete"
icon="delete"
shape="circle"
onClick={this.handleDeletePhoto}
/>
)}
</div>
<AvatarEdit
user={fields}
onDone={this.handleChangePhoto}
onDelete={this.handleDeletePhoto}
/>
<div className="ProfileEdit-info">
<Form
className="ProfileEdit-info-form"
@ -187,6 +173,13 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
};
private handleCancel = () => {
const { avatarUrl } = this.state.fields;
// cleanup uploaded file if we cancel
if (this.props.user.avatarUrl !== avatarUrl && avatarUrl) {
axios.delete('/api/v1/users/avatar', {
params: { url: avatarUrl },
});
}
this.props.onDone();
};
@ -226,13 +219,10 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
});
};
private handleChangePhoto = () => {
// TODO: Actual file uploading
const gender = ['men', 'women'][Math.floor(Math.random() * 2)];
const num = Math.floor(Math.random() * 80);
private handleChangePhoto = (url: string) => {
const fields = {
...this.state.fields,
avatarUrl: `https://randomuser.me/api/portraits/${gender}/${num}.jpg`,
avatarUrl: url,
};
const isChanged = this.isChangedCheck(fields);
this.setState({

View File

@ -1,4 +1,5 @@
import types from './types';
import usersTypes from 'modules/users/types';
// TODO: Use a common User type instead of this
import { TeamMember, AuthSignatureData } from 'types';
@ -56,6 +57,14 @@ export default function createReducer(
authSignatureAddress: action.payload.user.ethAddress,
isAuthingUser: false,
};
case usersTypes.UPDATE_USER_FULFILLED:
return {
...state,
user:
state.user && state.user.ethAddress === action.payload.user.ethAddress
? action.payload.user
: state.user,
};
case types.AUTH_USER_REJECTED:
return {
...state,

View File

@ -0,0 +1,35 @@
export function getBase64(img: Blob, callback: (base64: string) => void) {
const reader = new FileReader();
reader.addEventListener('load', () => callback(reader.result as string));
reader.readAsDataURL(img);
}
export function dataUrlToBlob(dataUrl: string) {
const base64ImageContent = dataUrl.replace(/^data:[a-z]+\/[a-z]+;base64,/, '');
const mimeSearch = dataUrl.match(/^data:([a-z]+\/[a-z]+)(;base64,)/);
if (!mimeSearch || mimeSearch.length !== 3) {
throw new Error(
'dataUrlToBlob could not find mime type, or base64 was missing: ' +
dataUrl.substring(0, 200),
);
} else {
return base64ToBlob(base64ImageContent, mimeSearch[1]);
}
}
export function base64ToBlob(base64: string, mime: string) {
mime = mime || '';
const sliceSize = 1024;
const byteChars = window.atob(base64);
const byteArrays = [];
for (let offset = 0, len = byteChars.length; offset < len; offset += sliceSize) {
const slice = byteChars.slice(offset, offset + sliceSize);
const byteNumbers = new Array(slice.length);
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
byteArrays.push(byteArray);
}
return new Blob(byteArrays, { type: mime });
}

View File

@ -55,6 +55,7 @@
"@types/node": "^10.3.1",
"@types/numeral": "^0.0.25",
"@types/react": "16.4.18",
"@types/react-cropper": "^0.10.3",
"@types/react-dom": "16.0.9",
"@types/react-helmet": "^5.0.7",
"@types/react-redux": "^6.0.2",
@ -118,6 +119,7 @@
"prettier-package-json": "^1.6.0",
"query-string": "6.1.0",
"react": "16.5.2",
"react-cropper": "^1.0.1",
"react-dev-utils": "^5.0.2",
"react-dom": "16.5.2",
"react-helmet": "^5.2.0",

View File

@ -1858,6 +1858,10 @@
dependencies:
"@types/express" "*"
"@types/cropperjs@*":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@types/cropperjs/-/cropperjs-1.1.3.tgz#8b2264fb45e933c3eda149cbd08a4f1926dfd8e2"
"@types/dotenv@^4.0.3":
version "4.0.3"
resolved "https://registry.yarnpkg.com/@types/dotenv/-/dotenv-4.0.3.tgz#ebcfc40da7bc0728b705945b7db48485ec5b4b67"
@ -1967,6 +1971,13 @@
version "1.2.2"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.2.tgz#fa8e1ad1d474688a757140c91de6dace6f4abc8d"
"@types/react-cropper@^0.10.3":
version "0.10.3"
resolved "https://registry.yarnpkg.com/@types/react-cropper/-/react-cropper-0.10.3.tgz#d7ca18667d9cdad9469d3de6469104924d8217d5"
dependencies:
"@types/cropperjs" "*"
"@types/react" "*"
"@types/react-dom@16.0.9":
version "16.0.9"
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.9.tgz#73ceb7abe6703822eab6600e65c5c52efd07fb91"
@ -4765,6 +4776,10 @@ create-react-context@0.2.3:
fbjs "^0.8.0"
gud "^1.0.0"
cropperjs@v1.0.0-rc.3:
version "1.0.0-rc.3"
resolved "https://registry.yarnpkg.com/cropperjs/-/cropperjs-1.0.0-rc.3.tgz#50a7c7611befc442702f845ede77d7df4572e82b"
cross-env@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.2.0.tgz#6ecd4c015d5773e614039ee529076669b9d126f2"
@ -12512,6 +12527,13 @@ react-copy-to-clipboard@^5.0.1:
copy-to-clipboard "^3"
prop-types "^15.5.8"
react-cropper@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/react-cropper/-/react-cropper-1.0.1.tgz#6e5595abb8576088ab3e51ecfdcfe5f865d0340f"
dependencies:
cropperjs v1.0.0-rc.3
prop-types "^15.5.8"
react-dev-utils@6.0.0-next.3e165448:
version "6.0.0-next.3e165448"
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-6.0.0-next.3e165448.tgz#d573ed0ba692f6cee23166f99204e5761df0897c"