diff --git a/api/config/settings/common.py b/api/config/settings/common.py index b74c2bdfe499af75053748ddb8d90b96b8b42760..c789c36af4c7e5005275fed2760760169aa78c6b 100644 --- a/api/config/settings/common.py +++ b/api/config/settings/common.py @@ -94,6 +94,7 @@ THIRD_PARTY_APPS = ( "django_filters", "cacheops", "django_cleanup", + "versatileimagefield", ) @@ -449,6 +450,7 @@ ACCOUNT_USERNAME_BLACKLIST = [ "superuser", "staff", "service", + "me", ] + env.list("ACCOUNT_USERNAME_BLACKLIST", default=[]) EXTERNAL_REQUESTS_VERIFY_SSL = env.bool("EXTERNAL_REQUESTS_VERIFY_SSL", default=True) diff --git a/api/funkwhale_api/common/utils.py b/api/funkwhale_api/common/utils.py index 221d2336b753322e5f40ad028c15fe157d218a00..bba4702b0957fea0c1a246fc512caa15ea0e30d2 100644 --- a/api/funkwhale_api/common/utils.py +++ b/api/funkwhale_api/common/utils.py @@ -1,5 +1,9 @@ +from django.utils.deconstruct import deconstructible + import os import shutil +import uuid + from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit from django.db import transaction @@ -41,3 +45,22 @@ def set_query_parameter(url, **kwargs): new_query_string = urlencode(query_params, doseq=True) return urlunsplit((scheme, netloc, path, new_query_string, fragment)) + + +@deconstructible +class ChunkedPath(object): + def __init__(self, root, preserve_file_name=True): + self.root = root + self.preserve_file_name = preserve_file_name + + def __call__(self, instance, filename): + uid = str(uuid.uuid4()) + chunk_size = 2 + chunks = [uid[i : i + chunk_size] for i in range(0, len(uid), chunk_size)] + if self.preserve_file_name: + parts = chunks[:3] + [filename] + else: + ext = os.path.splitext(filename)[1][1:].lower() + new_filename = "".join(chunks[3:]) + ".{}".format(ext) + parts = chunks[:3] + [new_filename] + return os.path.join(self.root, *parts) diff --git a/api/funkwhale_api/common/validators.py b/api/funkwhale_api/common/validators.py new file mode 100644 index 0000000000000000000000000000000000000000..b5f26cac5421450fccaac039720e1238cf118ca8 --- /dev/null +++ b/api/funkwhale_api/common/validators.py @@ -0,0 +1,152 @@ +import mimetypes +from os.path import splitext + +from django.core.exceptions import ValidationError +from django.core.files.images import get_image_dimensions +from django.template.defaultfilters import filesizeformat +from django.utils.deconstruct import deconstructible +from django.utils.translation import ugettext_lazy as _ + + +@deconstructible +class ImageDimensionsValidator: + """ + ImageField dimensions validator. + + from https://gist.github.com/emilio-rst/4f81ea2718736a6aaf9bdb64d5f2ea6c + """ + + def __init__( + self, + width=None, + height=None, + min_width=None, + max_width=None, + min_height=None, + max_height=None, + ): + """ + Constructor + + Args: + width (int): exact width + height (int): exact height + min_width (int): minimum width + min_height (int): minimum height + max_width (int): maximum width + max_height (int): maximum height + """ + + self.width = width + self.height = height + self.min_width = min_width + self.max_width = max_width + self.min_height = min_height + self.max_height = max_height + + def __call__(self, image): + w, h = get_image_dimensions(image) + + if self.width is not None and w != self.width: + raise ValidationError(_("Width must be %dpx.") % (self.width,)) + + if self.height is not None and h != self.height: + raise ValidationError(_("Height must be %dpx.") % (self.height,)) + + if self.min_width is not None and w < self.min_width: + raise ValidationError(_("Minimum width must be %dpx.") % (self.min_width,)) + + if self.min_height is not None and h < self.min_height: + raise ValidationError( + _("Minimum height must be %dpx.") % (self.min_height,) + ) + + if self.max_width is not None and w > self.max_width: + raise ValidationError(_("Maximum width must be %dpx.") % (self.max_width,)) + + if self.max_height is not None and h > self.max_height: + raise ValidationError( + _("Maximum height must be %dpx.") % (self.max_height,) + ) + + +@deconstructible +class FileValidator(object): + """ + Taken from https://gist.github.com/jrosebr1/2140738 + Validator for files, checking the size, extension and mimetype. + Initialization parameters: + allowed_extensions: iterable with allowed file extensions + ie. ('txt', 'doc') + allowd_mimetypes: iterable with allowed mimetypes + ie. ('image/png', ) + min_size: minimum number of bytes allowed + ie. 100 + max_size: maximum number of bytes allowed + ie. 24*1024*1024 for 24 MB + Usage example:: + MyModel(models.Model): + myfile = FileField(validators=FileValidator(max_size=24*1024*1024), ...) + """ + + extension_message = _( + "Extension '%(extension)s' not allowed. Allowed extensions are: '%(allowed_extensions)s.'" + ) + mime_message = _( + "MIME type '%(mimetype)s' is not valid. Allowed types are: %(allowed_mimetypes)s." + ) + min_size_message = _( + "The current file %(size)s, which is too small. The minumum file size is %(allowed_size)s." + ) + max_size_message = _( + "The current file %(size)s, which is too large. The maximum file size is %(allowed_size)s." + ) + + def __init__(self, *args, **kwargs): + self.allowed_extensions = kwargs.pop("allowed_extensions", None) + self.allowed_mimetypes = kwargs.pop("allowed_mimetypes", None) + self.min_size = kwargs.pop("min_size", 0) + self.max_size = kwargs.pop("max_size", None) + + def __call__(self, value): + """ + Check the extension, content type and file size. + """ + + # Check the extension + ext = splitext(value.name)[1][1:].lower() + if self.allowed_extensions and ext not in self.allowed_extensions: + message = self.extension_message % { + "extension": ext, + "allowed_extensions": ", ".join(self.allowed_extensions), + } + + raise ValidationError(message) + + # Check the content type + mimetype = mimetypes.guess_type(value.name)[0] + if self.allowed_mimetypes and mimetype not in self.allowed_mimetypes: + message = self.mime_message % { + "mimetype": mimetype, + "allowed_mimetypes": ", ".join(self.allowed_mimetypes), + } + + raise ValidationError(message) + + # Check the file size + filesize = len(value) + if self.max_size and filesize > self.max_size: + message = self.max_size_message % { + "size": filesizeformat(filesize), + "allowed_size": filesizeformat(self.max_size), + } + + raise ValidationError(message) + + elif filesize < self.min_size: + message = self.min_size_message % { + "size": filesizeformat(filesize), + "allowed_size": filesizeformat(self.min_size), + } + + raise ValidationError(message) diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 979b0674a94848dd7c0edb79f041900951629494..1d80395fe4a3fa6aa1e91def3d06612fc77653da 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -1,4 +1,3 @@ -import os import tempfile import uuid @@ -9,6 +8,7 @@ from django.db import models from django.utils import timezone from funkwhale_api.common import session +from funkwhale_api.common import utils as common_utils from funkwhale_api.music import utils as music_utils TYPE_CHOICES = [ @@ -141,12 +141,7 @@ class Library(models.Model): ) -def get_file_path(instance, filename): - uid = str(uuid.uuid4()) - chunk_size = 2 - chunks = [uid[i : i + chunk_size] for i in range(0, len(uid), chunk_size)] - parts = chunks[:3] + [filename] - return os.path.join("federation_cache", *parts) +get_file_path = common_utils.ChunkedPath("federation_cache") class LibraryTrack(models.Model): diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py index 205c7c36703ebfa153dab7b2a393f4432152e162..365db615efe10518cc207dbd05fd657065a64b85 100644 --- a/api/funkwhale_api/users/admin.py +++ b/api/funkwhale_api/users/admin.py @@ -56,7 +56,10 @@ class UserAdmin(AuthUserAdmin): fieldsets = ( (None, {"fields": ("username", "password", "privacy_level")}), - (_("Personal info"), {"fields": ("first_name", "last_name", "email")}), + ( + _("Personal info"), + {"fields": ("first_name", "last_name", "email", "avatar")}, + ), ( _("Permissions"), { diff --git a/api/funkwhale_api/users/migrations/0010_user_avatar.py b/api/funkwhale_api/users/migrations/0010_user_avatar.py new file mode 100644 index 0000000000000000000000000000000000000000..da60439becbe72c694129bc9f372927727810751 --- /dev/null +++ b/api/funkwhale_api/users/migrations/0010_user_avatar.py @@ -0,0 +1,20 @@ +# Generated by Django 2.0.6 on 2018-07-10 20:09 + +from django.db import migrations, models +import funkwhale_api.common.utils +import funkwhale_api.common.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0009_auto_20180619_2024'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='avatar', + field=models.ImageField(blank=True, max_length=150, null=True, upload_to=funkwhale_api.common.utils.ChunkedPath('users/avatars'), validators=[funkwhale_api.common.validators.ImageDimensionsValidator(max_height=400, max_width=400, min_height=50, min_width=50)]), + ), + ] diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py index ec9c39fd69a47d08f08f2a59ba20f480e8cfdb9c..a56406d8bb92f80f9d060930cc902706919a2ed2 100644 --- a/api/funkwhale_api/users/models.py +++ b/api/funkwhale_api/users/models.py @@ -16,7 +16,11 @@ from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ +from versatileimagefield.fields import VersatileImageField + from funkwhale_api.common import fields, preferences +from funkwhale_api.common import utils as common_utils +from funkwhale_api.common import validators as common_validators def get_token(): @@ -39,6 +43,9 @@ PERMISSIONS_CONFIGURATION = { PERMISSIONS = sorted(PERMISSIONS_CONFIGURATION.keys()) +get_file_path = common_utils.ChunkedPath("users/avatars", preserve_file_name=False) + + @python_2_unicode_compatible class User(AbstractUser): @@ -88,6 +95,19 @@ class User(AbstractUser): blank=True, on_delete=models.SET_NULL, ) + avatar = VersatileImageField( + upload_to=get_file_path, + null=True, + blank=True, + max_length=150, + validators=[ + common_validators.ImageDimensionsValidator(min_width=50, min_height=50), + common_validators.FileValidator( + allowed_extensions=["png", "jpg", "jpeg", "gif"], + max_size=1024 * 1024 * 2, + ), + ], + ) def __str__(self): return self.username diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 4389512650327a2da66fcd35fee362106814d43a..fd007e234747ec0eaa4591231c0f97b0b2863732 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -3,6 +3,8 @@ from rest_auth.serializers import PasswordResetSerializer as PRS from rest_auth.registration.serializers import RegisterSerializer as RS from rest_framework import serializers +from versatileimagefield.serializers import VersatileImageFieldSerializer + from funkwhale_api.activity import serializers as activity_serializers from . import models @@ -49,15 +51,29 @@ class UserBasicSerializer(serializers.ModelSerializer): fields = ["id", "username", "name", "date_joined"] +avatar_field = VersatileImageFieldSerializer( + allow_null=True, + sizes=[ + ("original", "url"), + ("square_crop", "crop__400x400"), + ("medium_square_crop", "crop__200x200"), + ("small_square_crop", "crop__50x50"), + ], +) + + class UserWriteSerializer(serializers.ModelSerializer): + avatar = avatar_field + class Meta: model = models.User - fields = ["name", "privacy_level"] + fields = ["name", "privacy_level", "avatar"] class UserReadSerializer(serializers.ModelSerializer): permissions = serializers.SerializerMethodField() + avatar = avatar_field class Meta: model = models.User @@ -71,6 +87,7 @@ class UserReadSerializer(serializers.ModelSerializer): "permissions", "date_joined", "privacy_level", + "avatar", ] def get_permissions(self, o): diff --git a/api/requirements/base.txt b/api/requirements/base.txt index 13c0efdbc7df9547a6991fc589d37312de98909d..ed179a89780fd70e26ed826a9b97fc92550ed91f 100644 --- a/api/requirements/base.txt +++ b/api/requirements/base.txt @@ -37,7 +37,7 @@ oauth2client<4 google-api-python-client>=1.6,<1.7 arrow>=0.12,<0.13 persisting-theory>=0.2,<0.3 -django-versatileimagefield>=1.8,<1.9 +django-versatileimagefield>=1.9,<1.10 django-filter>=1.1,<1.2 django-rest-auth>=0.9,<0.10 beautifulsoup4>=4.6,<4.7 diff --git a/api/tests/conftest.py b/api/tests/conftest.py index aa36e1f76f60d836a33b0c82a44be55a4f3cb372..fc7e11947c84ed72bbf9446d22d08a6c41e737f9 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -1,4 +1,7 @@ import datetime +import io +import PIL +import random import shutil import tempfile @@ -258,3 +261,14 @@ def now(mocker): now = timezone.now() mocker.patch("django.utils.timezone.now", return_value=now) return now + + +@pytest.fixture() +def avatar(): + i = PIL.Image.new("RGBA", (400, 400), random.choice(["red", "blue", "yellow"])) + f = io.BytesIO() + i.save(f, "png") + f.name = "avatar.png" + f.seek(0) + yield f + f.close() diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index fca66d302efc499cb7ef7cf0c2d8124ec7b5668b..9bea4ced391b01c841700c8556f7891f6008acb9 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -235,3 +235,17 @@ def test_user_cannot_patch_another_user(method, logged_in_api_client, factories) response = handler(url, payload) assert response.status_code == 403 + + +def test_user_can_patch_their_own_avatar(logged_in_api_client, avatar): + user = logged_in_api_client.user + url = reverse("api:v1:users:users-detail", kwargs={"username": user.username}) + content = avatar.read() + avatar.seek(0) + payload = {"avatar": avatar} + response = logged_in_api_client.patch(url, payload) + + assert response.status_code == 200 + user.refresh_from_db() + + assert user.avatar.read() == content diff --git a/changes/changelog.d/257.feature b/changes/changelog.d/257.feature new file mode 100644 index 0000000000000000000000000000000000000000..c2e25ff3263d3ac4479e5238558410b621b299e3 --- /dev/null +++ b/changes/changelog.d/257.feature @@ -0,0 +1 @@ +Users can now upload an avatar in their settings page (#257) diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue index 4765823f76c2ee1524a60035cb6de1d38b1d2356..938a48070078c42364c96150292c9467784265a3 100644 --- a/front/src/components/Sidebar.vue +++ b/front/src/components/Sidebar.vue @@ -39,6 +39,7 @@ <translate :translate-params="{username: $store.state.auth.username}"> Logged in as %{ username } </translate> + <img class="ui avatar right floated circular mini image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" /> </router-link> <router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i><translate>Logout</translate></router-link> <router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i><translate>Login</translate></router-link> @@ -432,4 +433,8 @@ $sidebar-color: #3d3e3f; } } } +.avatar { + position: relative; + top: -0.5em; +} </style> diff --git a/front/src/components/auth/Profile.vue b/front/src/components/auth/Profile.vue index 556a9a67e65846517e9d07b069f75c7a4ca6568a..23af78c616a3be26a63919812c7067442c678041 100644 --- a/front/src/components/auth/Profile.vue +++ b/front/src/components/auth/Profile.vue @@ -3,19 +3,20 @@ <div v-if="isLoading" class="ui vertical segment"> <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> - <template v-if="$store.state.auth.profile"> + <template v-if="profile"> <div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']"> <h2 class="ui center aligned icon header"> - <i class="circular inverted user green icon"></i> + <i v-if="!profile.avatar.square_crop" class="circular inverted user green icon"></i> + <img class="ui big circular image" v-else :src="$store.getters['instance/absoluteUrl'](profile.avatar.square_crop)" /> <div class="content"> - {{ $store.state.auth.profile.username }} + {{ profile.username }} <div class="sub header" v-translate="{date: signupDate}">Registered since %{ date }</div> </div> </h2> <div class="ui basic green label"> <translate>This is you!</translate> </div> - <div v-if="$store.state.auth.profile.is_staff" class="ui yellow label"> + <div v-if="profile.is_staff" class="ui yellow label"> <i class="star icon"></i> <translate>Staff member</translate> </div> @@ -30,15 +31,20 @@ </template> <script> +import {mapState} from 'vuex' + const dateFormat = require('dateformat') export default { - name: 'login', props: ['username'], created () { this.$store.dispatch('auth/fetchProfile') }, computed: { + + ...mapState({ + profile: state => state.auth.profile + }), labels () { let msg = this.$gettext('%{ username }\'s profile') let usernameProfile = this.$gettextInterpolate(msg, {username: this.username}) @@ -47,11 +53,11 @@ export default { } }, signupDate () { - let d = new Date(this.$store.state.auth.profile.date_joined) + let d = new Date(this.profile.date_joined) return dateFormat(d, 'longDate') }, isLoading () { - return !this.$store.state.auth.profile + return !this.profile } } } @@ -59,4 +65,7 @@ export default { <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped> +.ui.header > img.image { + width: 8em; +} </style> diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 886fe598dc98c3406e00fd2b0d2cea3140fefb4e..1e5a9ecedfc146e7d7c732e77a3c4f50ec54ca84 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -30,6 +30,39 @@ </form> </div> <div class="ui hidden divider"></div> + <div class="ui small text container"> + <h2 class="ui header"> + <translate>Avatar</translate> + </h2> + <div class="ui form"> + <div v-if="avatarErrors.length > 0" class="ui negative message"> + <div class="header"><translate>We cannot save your avatar</translate></div> + <ul class="list"> + <li v-for="error in avatarErrors">{{ error }}</li> + </ul> + </div> + <div class="ui stackable grid"> + <div class="ui ten wide column"> + <h3 class="ui header"><translate>Upload a new avatar</translate></h3> + <p><translate>PNG, GIF or JPG. At most 2MB. Will be downscaled to 400x400px.</translate></p> + <input class="ui input" ref="avatar" type="file" /> + <div class="ui hidden divider"></div> + <button @click="submitAvatar" :class="['ui', {'loading': isLoadingAvatar}, 'button']"> + <translate>Update avatar</translate> + </button> + </div> + <div class="ui six wide column"> + <h3 class="ui header"><translate>Current avatar</translate></h3> + <img class="ui circular image" v-if="currentAvatar && currentAvatar.square_crop" :src="$store.getters['instance/absoluteUrl'](currentAvatar.medium_square_crop)" /> + <div class="ui hidden divider"></div> + <button @click="removeAvatar" v-if="currentAvatar && currentAvatar.square_crop" :class="['ui', {'loading': isLoadingAvatar}, ,'yellow', 'button']"> + <translate>Remove avatar</translate> + </button> + </div> + </div> + </div> + </div> + <div class="ui hidden divider"></div> <div class="ui small text container"> <h2 class="ui header"> <translate>Change my password</translate> @@ -97,8 +130,12 @@ export default { // properties that will be used in it old_password: '', new_password: '', + currentAvatar: this.$store.state.auth.profile.avatar, passwordError: '', isLoading: false, + isLoadingAvatar: false, + avatarErrors: [], + avatar: null, settings: { success: false, errors: [], @@ -147,6 +184,46 @@ export default { self.settings.errors = error.backendErrors }) }, + submitAvatar () { + this.isLoadingAvatar = true + this.avatarErrors = [] + let self = this + this.avatar = this.$refs.avatar.files[0] + let formData = new FormData() + formData.append('avatar', this.avatar) + axios.patch( + `users/users/${this.$store.state.auth.username}/`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data' + } + } + ).then(response => { + this.isLoadingAvatar = false + self.currentAvatar = response.data.avatar + self.$store.commit('auth/avatar', self.currentAvatar) + }, error => { + self.isLoadingAvatar = false + self.avatarErrors = error.backendErrors + }) + }, + removeAvatar () { + this.isLoadingAvatar = true + let self = this + this.avatar = null + axios.patch( + `users/users/${this.$store.state.auth.username}/`, + {avatar: null} + ).then(response => { + this.isLoadingAvatar = false + self.currentAvatar = {} + self.$store.commit('auth/avatar', self.currentAvatar) + }, error => { + self.isLoadingAvatar = false + self.avatarErrors = error.backendErrors + }) + }, submitPassword () { var self = this self.isLoading = true diff --git a/front/src/store/auth.js b/front/src/store/auth.js index d36366996c5ea49b546e1c7394d00f00bd28858e..098dab708797cc19cc464488698f99fc9d49e9a4 100644 --- a/front/src/store/auth.js +++ b/front/src/store/auth.js @@ -47,6 +47,11 @@ export default { username: (state, value) => { state.username = value }, + avatar: (state, value) => { + if (state.profile) { + state.profile.avatar = value + } + }, token: (state, value) => { state.token = value if (value) {