diff --git a/backend/.env.example b/backend/.env.example index cde71398..94f350b2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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" \ No newline at end of file +ETHEREUM_ENDPOINT_URI = "http://localhost:8545" +UPLOAD_DIRECTORY = "/tmp" +UPLOAD_URL = "http://localhost:5000" # for constructing download url \ No newline at end of file diff --git a/backend/grant/settings.py b/backend/grant/settings.py index a818349f..c23f1e60 100644 --- a/backend/grant/settings.py +++ b/backend/grant/settings.py @@ -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) diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index 64d3757e..a7e5ae00 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -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/", 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("/", 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 diff --git a/backend/grant/utils/upload.py b/backend/grant/utils/upload.py new file mode 100644 index 00000000..24cb368c --- /dev/null +++ b/backend/grant/utils/upload.py @@ -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)) diff --git a/frontend/client/components/Profile/AvatarEdit.less b/frontend/client/components/Profile/AvatarEdit.less new file mode 100644 index 00000000..b8bc82f8 --- /dev/null +++ b/frontend/client/components/Profile/AvatarEdit.less @@ -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; + } + } + } +} diff --git a/frontend/client/components/Profile/AvatarEdit.tsx b/frontend/client/components/Profile/AvatarEdit.tsx new file mode 100644 index 00000000..eab710d6 --- /dev/null +++ b/frontend/client/components/Profile/AvatarEdit.tsx @@ -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 { + state = initialState; + cropperRef: React.RefObject; + 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 ( + <> + {' '} +
+ + + + + {avatarUrl && ( +
+ + Cancel + , + , + ]} + > + + {uploadError && ( + + )} + + + ); + } + + 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 }); + }); + }); + }; +} diff --git a/frontend/client/components/Profile/ProfileEdit.less b/frontend/client/components/Profile/ProfileEdit.less index 728700f7..3f421e9c 100644 --- a/frontend/client/components/Profile/ProfileEdit.less +++ b/frontend/client/components/Profile/ProfileEdit.less @@ -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; diff --git a/frontend/client/components/Profile/ProfileEdit.tsx b/frontend/client/components/Profile/ProfileEdit.tsx index 974b2a1b..d22609f7 100644 --- a/frontend/client/components/Profile/ProfileEdit.tsx +++ b/frontend/client/components/Profile/ProfileEdit.tsx @@ -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 { return ( <>
-
- - - {fields.avatarUrl && ( -
+ +
{ }; 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 { }); }; - 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({ diff --git a/frontend/client/modules/auth/reducers.ts b/frontend/client/modules/auth/reducers.ts index 4bfa9085..c23aed2b 100644 --- a/frontend/client/modules/auth/reducers.ts +++ b/frontend/client/modules/auth/reducers.ts @@ -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, diff --git a/frontend/client/utils/blob.ts b/frontend/client/utils/blob.ts new file mode 100644 index 00000000..94965702 --- /dev/null +++ b/frontend/client/utils/blob.ts @@ -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 }); +} diff --git a/frontend/package.json b/frontend/package.json index 3537cf85..5322e8ee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 06200a59..1ad8dd0f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -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"