Federation of avatars

This commit is contained in:
Eliot Berriot 2020-01-23 16:38:04 +01:00
parent b86971c305
commit f107656586
No known key found for this signature in database
GPG Key ID: 6B501DFD73514E14
20 changed files with 214 additions and 126 deletions

View File

@ -180,6 +180,7 @@ class AttachmentQuerySet(models.QuerySet):
"mutation_attachment", "mutation_attachment",
"covered_track", "covered_track",
"covered_artist", "covered_artist",
"iconed_actor",
] ]
query = None query = None
for field in related_fields: for field in related_fields:

View File

@ -5,13 +5,9 @@ Compute different sizes of image used for Album covers and User avatars
from versatileimagefield.image_warmer import VersatileImageFieldWarmer from versatileimagefield.image_warmer import VersatileImageFieldWarmer
from funkwhale_api.common.models import Attachment from funkwhale_api.common.models import Attachment
from funkwhale_api.music.models import Album
from funkwhale_api.users.models import User
MODELS = [ MODELS = [
(Album, "cover", "square"),
(User, "avatar", "square"),
(Attachment, "file", "attachment_square"), (Attachment, "file", "attachment_square"),
] ]

View File

@ -24,6 +24,7 @@ class RelatedField(serializers.RelatedField):
self.related_field_name = related_field_name self.related_field_name = related_field_name
self.serializer = serializer self.serializer = serializer
self.filters = kwargs.pop("filters", None) self.filters = kwargs.pop("filters", None)
self.queryset_filter = kwargs.pop("queryset_filter", None)
try: try:
kwargs["queryset"] = kwargs.pop("queryset") kwargs["queryset"] = kwargs.pop("queryset")
except KeyError: except KeyError:
@ -36,10 +37,16 @@ class RelatedField(serializers.RelatedField):
filters.update(self.filters(self.context)) filters.update(self.filters(self.context))
return filters return filters
def filter_queryset(self, queryset):
if self.queryset_filter:
queryset = self.queryset_filter(queryset, self.context)
return queryset
def to_internal_value(self, data): def to_internal_value(self, data):
try: try:
queryset = self.get_queryset() queryset = self.get_queryset()
filters = self.get_filters(data) filters = self.get_filters(data)
queryset = self.filter_queryset(queryset)
return queryset.get(**filters) return queryset.get(**filters)
except ObjectDoesNotExist: except ObjectDoesNotExist:
self.fail( self.fail(
@ -318,3 +325,16 @@ class ContentSerializer(serializers.Serializer):
def get_html(self, o): def get_html(self, o):
return utils.render_html(o.text, o.content_type) return utils.render_html(o.text, o.content_type)
class NullToEmptDict(object):
def get_attribute(self, o):
attr = super().get_attribute(o)
if attr is None:
return {}
return attr
def to_representation(self, v):
if not v:
return v
return super().to_representation(v)

View File

@ -327,8 +327,11 @@ def attach_file(obj, field, file_data, fetch=False):
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"} extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
extension = extensions.get(file_data["mimetype"], "jpg") extension = extensions.get(file_data["mimetype"], "jpg")
attachment = models.Attachment(mimetype=file_data["mimetype"]) attachment = models.Attachment(mimetype=file_data["mimetype"])
name_fields = ["uuid", "full_username", "pk"]
filename = "cover-{}.{}".format(obj.uuid, extension) name = [getattr(obj, field) for field in name_fields if getattr(obj, field, None)][
0
]
filename = "{}-{}.{}".format(field, name, extension)
if "url" in file_data: if "url" in file_data:
attachment.url = file_data["url"] attachment.url = file_data["url"]
else: else:

View File

@ -22,7 +22,9 @@ class TrackFavoriteViewSet(
filterset_class = filters.TrackFavoriteFilter filterset_class = filters.TrackFavoriteFilter
serializer_class = serializers.UserTrackFavoriteSerializer serializer_class = serializers.UserTrackFavoriteSerializer
queryset = models.TrackFavorite.objects.all().select_related("user__actor") queryset = models.TrackFavorite.objects.all().select_related(
"user__actor__attachment_icon"
)
permission_classes = [ permission_classes = [
oauth_permissions.ScopePermission, oauth_permissions.ScopePermission,
permissions.OwnerPermission, permissions.OwnerPermission,

View File

@ -0,0 +1,20 @@
# Generated by Django 2.2.9 on 2020-01-23 13:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('common', '0007_auto_20200116_1610'),
('federation', '0023_actor_summary_obj'),
]
operations = [
migrations.AddField(
model_name='actor',
name='attachment_icon',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='iconed_actor', to='common.Attachment'),
),
]

View File

@ -205,6 +205,13 @@ class Actor(models.Model):
through_fields=("target", "actor"), through_fields=("target", "actor"),
related_name="following", related_name="following",
) )
attachment_icon = models.ForeignKey(
"common.Attachment",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="iconed_actor",
)
objects = ActorQuerySet.as_manager() objects = ActorQuerySet.as_manager()

View File

@ -1,9 +1,7 @@
import logging import logging
import mimetypes
import urllib.parse import urllib.parse
import uuid import uuid
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import transaction from django.db import transaction
@ -97,6 +95,9 @@ class ActorSerializer(jsonld.JsonLdSerializer):
following = serializers.URLField(max_length=500, required=False, allow_null=True) following = serializers.URLField(max_length=500, required=False, allow_null=True)
publicKey = PublicKeySerializer(required=False) publicKey = PublicKeySerializer(required=False)
endpoints = EndpointsSerializer(required=False) endpoints = EndpointsSerializer(required=False)
icon = LinkSerializer(
allowed_mimetypes=["image/*"], allow_null=True, required=False
)
class Meta: class Meta:
jsonld_mapping = { jsonld_mapping = {
@ -113,6 +114,7 @@ class ActorSerializer(jsonld.JsonLdSerializer):
), ),
"mediaType": jsonld.first_val(contexts.AS.mediaType), "mediaType": jsonld.first_val(contexts.AS.mediaType),
"endpoints": jsonld.first_obj(contexts.AS.endpoints), "endpoints": jsonld.first_obj(contexts.AS.endpoints),
"icon": jsonld.first_obj(contexts.AS.icon),
} }
def to_representation(self, instance): def to_representation(self, instance):
@ -143,17 +145,11 @@ class ActorSerializer(jsonld.JsonLdSerializer):
"id": "{}#main-key".format(instance.fid), "id": "{}#main-key".format(instance.fid),
} }
ret["endpoints"] = {} ret["endpoints"] = {}
include_image(ret, instance.attachment_icon, "icon")
if instance.shared_inbox_url: if instance.shared_inbox_url:
ret["endpoints"]["sharedInbox"] = instance.shared_inbox_url ret["endpoints"]["sharedInbox"] = instance.shared_inbox_url
try:
if instance.user.avatar:
ret["icon"] = {
"type": "Image",
"mediaType": mimetypes.guess_type(instance.user.avatar_path)[0],
"url": utils.full_url(instance.user.avatar.crop["400x400"].url),
}
except ObjectDoesNotExist:
pass
return ret return ret
def prepare_missing_fields(self): def prepare_missing_fields(self):
@ -201,6 +197,15 @@ class ActorSerializer(jsonld.JsonLdSerializer):
common_utils.attach_content( common_utils.attach_content(
actor, "summary_obj", self.validated_data["summary"] actor, "summary_obj", self.validated_data["summary"]
) )
if "icon" in self.validated_data:
new_value = self.validated_data["icon"]
common_utils.attach_file(
actor,
"attachment_icon",
{"url": new_value["href"], "mimetype": new_value["mediaType"]}
if new_value
else None,
)
return actor return actor
def validate(self, data): def validate(self, data):
@ -844,15 +849,15 @@ def include_content(repr, content_obj):
repr["mediaType"] = "text/html" repr["mediaType"] = "text/html"
def include_image(repr, attachment): def include_image(repr, attachment, field="image"):
if attachment: if attachment:
repr["image"] = { repr[field] = {
"type": "Image", "type": "Image",
"href": attachment.download_url_original, "href": attachment.download_url_original,
"mediaType": attachment.mimetype or "image/jpeg", "mediaType": attachment.mimetype or "image/jpeg",
} }
else: else:
repr["image"] = None repr[field] = None
class MusicEntitySerializer(jsonld.JsonLdSerializer): class MusicEntitySerializer(jsonld.JsonLdSerializer):

View File

@ -19,7 +19,9 @@ class ListeningViewSet(
): ):
serializer_class = serializers.ListeningSerializer serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related("user__actor") queryset = models.Listening.objects.all().select_related(
"user__actor__attachment_icon"
)
permission_classes = [ permission_classes = [
oauth_permissions.ScopePermission, oauth_permissions.ScopePermission,

View File

@ -61,7 +61,9 @@ class DescriptionMutation(mutations.UpdateMutationSerializer):
class CoverMutation(mutations.UpdateMutationSerializer): class CoverMutation(mutations.UpdateMutationSerializer):
cover = common_serializers.RelatedField( cover = common_serializers.RelatedField(
"uuid", queryset=common_models.Attachment.objects.all().local(), serializer=None "uuid",
queryset=common_models.Attachment.objects.all().local(),
serializer=None,
) )
def get_serialized_relations(self): def get_serialized_relations(self):

View File

@ -18,20 +18,9 @@ from funkwhale_api.tags import serializers as tags_serializers
from . import filters, models, tasks from . import filters, models, tasks
class NullToEmptDict(object): class CoverField(
def get_attribute(self, o): common_serializers.NullToEmptDict, common_serializers.AttachmentSerializer
attr = super().get_attribute(o) ):
if attr is None:
return {}
return attr
def to_representation(self, v):
if not v:
return v
return super().to_representation(v)
class CoverField(NullToEmptDict, common_serializers.AttachmentSerializer):
# XXX: BACKWARD COMPATIBILITY # XXX: BACKWARD COMPATIBILITY
pass pass

View File

@ -23,7 +23,7 @@ class PlaylistViewSet(
serializer_class = serializers.PlaylistSerializer serializer_class = serializers.PlaylistSerializer
queryset = ( queryset = (
models.Playlist.objects.all() models.Playlist.objects.all()
.select_related("user__actor") .select_related("user__actor__attachment_icon")
.annotate(tracks_count=Count("playlist_tracks", distinct=True)) .annotate(tracks_count=Count("playlist_tracks", distinct=True))
.with_covers() .with_covers()
.with_duration() .with_duration()

View File

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
def create_attachments(apps, schema_editor):
Actor = apps.get_model("federation", "Actor")
User = apps.get_model("users", "User")
Attachment = apps.get_model("common", "Attachment")
obj_attachment_mapping = {}
def get_mimetype(path):
if path.lower().endswith('.png'):
return "image/png"
return "image/jpeg"
qs = User.objects.filter(actor__attachment_icon=None).exclude(avatar="").exclude(avatar=None).exclude(actor=None).select_related('actor')
total = qs.count()
print('Creating attachments for {} user avatars, this may take a while…'.format(total))
from django.core.files.storage import FileSystemStorage
for i, user in enumerate(qs):
if isinstance(user.avatar.storage._wrapped, FileSystemStorage):
try:
size = user.avatar.size
except FileNotFoundError:
# can occur when file isn't found on disk or S3
print(" Warning: avatar file wasn't found in storage: {}".format(e.__class__))
size = None
obj_attachment_mapping[user.actor] = Attachment(
file=user.avatar,
size=size,
mimetype=get_mimetype(user.avatar.name),
)
print('Commiting changes…')
Attachment.objects.bulk_create(obj_attachment_mapping.values(), batch_size=2000)
# map each attachment to the corresponding obj
# and bulk save
for obj, attachment in obj_attachment_mapping.items():
obj.attachment_icon = attachment
Actor.objects.bulk_update(obj_attachment_mapping.keys(), fields=['attachment_icon'], batch_size=2000)
def rewind(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [("users", "0016_auto_20190920_0857"), ("federation", "0024_actor_attachment_icon")]
operations = [migrations.RunPython(create_attachments, rewind)]

View File

@ -21,7 +21,6 @@ from django_auth_ldap.backend import populate_user as ldap_populate_user
from oauth2_provider import models as oauth2_models from oauth2_provider import models as oauth2_models
from oauth2_provider import validators as oauth2_validators from oauth2_provider import validators as oauth2_validators
from versatileimagefield.fields import VersatileImageField from versatileimagefield.fields import VersatileImageField
from versatileimagefield.image_warmer import VersatileImageFieldWarmer
from funkwhale_api.common import fields, preferences from funkwhale_api.common import fields, preferences
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
@ -413,13 +412,3 @@ def create_actor(user):
def init_ldap_user(sender, user, ldap_user, **kwargs): def init_ldap_user(sender, user, ldap_user, **kwargs):
if not user.actor: if not user.actor:
user.actor = create_actor(user) user.actor = create_actor(user)
@receiver(models.signals.post_save, sender=User)
def warm_user_avatar(sender, instance, **kwargs):
if not instance.avatar or not settings.CREATE_IMAGE_THUMBNAILS:
return
user_avatar_warmer = VersatileImageFieldWarmer(
instance_or_queryset=instance, rendition_key_set="square", image_attr="avatar"
)
num_created, failed_to_create = user_avatar_warmer.warm()

View File

@ -7,9 +7,9 @@ from django.utils.translation import gettext_lazy as _
from rest_auth.serializers import PasswordResetSerializer as PRS from rest_auth.serializers import PasswordResetSerializer as PRS
from rest_auth.registration.serializers import RegisterSerializer as RS, get_adapter from rest_auth.registration.serializers import RegisterSerializer as RS, get_adapter
from rest_framework import serializers from rest_framework import serializers
from versatileimagefield.serializers import VersatileImageFieldSerializer
from funkwhale_api.activity import serializers as activity_serializers from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.common import models as common_models
from funkwhale_api.common import serializers as common_serializers from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.common import utils as common_utils from funkwhale_api.common import utils as common_utils
from funkwhale_api.federation import models as federation_models from funkwhale_api.federation import models as federation_models
@ -89,26 +89,30 @@ class UserActivitySerializer(activity_serializers.ModelSerializer):
return "Person" return "Person"
class AvatarField(
common_serializers.StripExifImageField, VersatileImageFieldSerializer
):
pass
avatar_field = AvatarField(allow_null=True, sizes="square")
class UserBasicSerializer(serializers.ModelSerializer): class UserBasicSerializer(serializers.ModelSerializer):
avatar = avatar_field avatar = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.User model = models.User
fields = ["id", "username", "name", "date_joined", "avatar"] fields = ["id", "username", "name", "date_joined", "avatar"]
def get_avatar(self, o):
return common_serializers.AttachmentSerializer(
o.actor.attachment_icon if o.actor else None
).data
class UserWriteSerializer(serializers.ModelSerializer): class UserWriteSerializer(serializers.ModelSerializer):
avatar = avatar_field
summary = common_serializers.ContentSerializer(required=False, allow_null=True) summary = common_serializers.ContentSerializer(required=False, allow_null=True)
avatar = common_serializers.RelatedField(
"uuid",
queryset=common_models.Attachment.objects.all().local().attached(False),
serializer=None,
queryset_filter=lambda qs, context: qs.filter(
actor=context["request"].user.actor
),
write_only=True,
)
class Meta: class Meta:
model = models.User model = models.User
@ -125,19 +129,30 @@ class UserWriteSerializer(serializers.ModelSerializer):
if not obj.actor: if not obj.actor:
obj.create_actor() obj.create_actor()
summary = validated_data.pop("summary", NOOP) summary = validated_data.pop("summary", NOOP)
avatar = validated_data.pop("avatar", NOOP)
obj = super().update(obj, validated_data) obj = super().update(obj, validated_data)
if summary != NOOP: if summary != NOOP:
common_utils.attach_content(obj.actor, "summary_obj", summary) common_utils.attach_content(obj.actor, "summary_obj", summary)
if avatar != NOOP:
obj.actor.attachment_icon = avatar
obj.actor.save(update_fields=["attachment_icon"])
return obj return obj
def to_representation(self, obj):
repr = super().to_representation(obj)
repr["avatar"] = common_serializers.AttachmentSerializer(
obj.actor.attachment_icon
).data
return repr
class UserReadSerializer(serializers.ModelSerializer): class UserReadSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField() permissions = serializers.SerializerMethodField()
full_username = serializers.SerializerMethodField() full_username = serializers.SerializerMethodField()
avatar = avatar_field avatar = serializers.SerializerMethodField()
class Meta: class Meta:
model = models.User model = models.User
@ -155,6 +170,9 @@ class UserReadSerializer(serializers.ModelSerializer):
"avatar", "avatar",
] ]
def get_avatar(self, o):
return common_serializers.AttachmentSerializer(o.actor.attachment_icon).data
def get_permissions(self, o): def get_permissions(self, o):
return o.get_permissions() return o.get_permissions()

View File

@ -44,7 +44,7 @@ class PasswordResetConfirmView(rest_auth_views.PasswordResetConfirmView):
class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet): class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
queryset = models.User.objects.all() queryset = models.User.objects.all().select_related("actor__attachment_icon")
serializer_class = serializers.UserWriteSerializer serializer_class = serializers.UserWriteSerializer
lookup_field = "username" lookup_field = "username"
lookup_value_regex = r"[a-zA-Z0-9-_.]+" lookup_value_regex = r"[a-zA-Z0-9-_.]+"

View File

@ -36,6 +36,11 @@ def test_actor_serializer_from_ap(db):
"id": actor_url + "#main-key", "id": actor_url + "#main-key",
}, },
"endpoints": {"sharedInbox": "https://noop.url/federation/shared/inbox"}, "endpoints": {"sharedInbox": "https://noop.url/federation/shared/inbox"},
"icon": {
"type": "Image",
"mediaType": "image/jpeg",
"href": "https://image.example/image.png",
},
} }
serializer = serializers.ActorSerializer(data=payload) serializer = serializers.ActorSerializer(data=payload)
@ -60,6 +65,8 @@ def test_actor_serializer_from_ap(db):
assert actor.private_key is None assert actor.private_key is None
assert actor.public_key == payload["publicKey"]["publicKeyPem"] assert actor.public_key == payload["publicKey"]["publicKeyPem"]
assert actor.domain_id == "test.federation" assert actor.domain_id == "test.federation"
assert actor.attachment_icon.url == payload["icon"]["href"]
assert actor.attachment_icon.mimetype == payload["icon"]["mediaType"]
def test_actor_serializer_only_mandatory_field_from_ap(db): def test_actor_serializer_only_mandatory_field_from_ap(db):
@ -90,7 +97,7 @@ def test_actor_serializer_only_mandatory_field_from_ap(db):
assert actor.manually_approves_followers is None assert actor.manually_approves_followers is None
def test_actor_serializer_to_ap(db): def test_actor_serializer_to_ap(factories):
expected = { expected = {
"@context": jsonld.get_default_context(), "@context": jsonld.get_default_context(),
"id": "https://test.federation/user", "id": "https://test.federation/user",
@ -122,12 +129,18 @@ def test_actor_serializer_to_ap(db):
domain=models.Domain.objects.create(pk="test.federation"), domain=models.Domain.objects.create(pk="test.federation"),
type="Person", type="Person",
manually_approves_followers=False, manually_approves_followers=False,
attachment_icon=factories["common.Attachment"](),
) )
content = common_utils.attach_content( content = common_utils.attach_content(
ac, "summary_obj", {"text": "hello world", "content_type": "text/markdown"} ac, "summary_obj", {"text": "hello world", "content_type": "text/markdown"}
) )
expected["summary"] = content.rendered expected["summary"] = content.rendered
expected["icon"] = {
"type": "Image",
"mediaType": "image/jpeg",
"href": utils.full_url(ac.attachment_icon.file.url),
}
serializer = serializers.ActorSerializer(ac) serializer = serializers.ActorSerializer(ac)
assert serializer.data == expected assert serializer.data == expected
@ -1133,6 +1146,7 @@ def test_local_actor_serializer_to_ap(factories):
domain=models.Domain.objects.create(pk="test.federation"), domain=models.Domain.objects.create(pk="test.federation"),
type="Person", type="Person",
manually_approves_followers=False, manually_approves_followers=False,
attachment_icon=factories["common.Attachment"](),
) )
content = common_utils.attach_content( content = common_utils.attach_content(
ac, "summary_obj", {"text": "hello world", "content_type": "text/markdown"} ac, "summary_obj", {"text": "hello world", "content_type": "text/markdown"}
@ -1145,7 +1159,7 @@ def test_local_actor_serializer_to_ap(factories):
expected["icon"] = { expected["icon"] = {
"type": "Image", "type": "Image",
"mediaType": "image/jpeg", "mediaType": "image/jpeg",
"url": utils.full_url(user.avatar.crop["400x400"].url), "href": utils.full_url(ac.attachment_icon.file.url),
} }
serializer = serializers.ActorSerializer(ac) serializer = serializers.ActorSerializer(ac)

View File

@ -110,7 +110,9 @@ def test_can_fetch_data_from_api(api_client, factories):
user = factories["users.User"](permission_library=True, with_actor=True) user = factories["users.User"](permission_library=True, with_actor=True)
summary = {"content_type": "text/plain", "text": "Hello"} summary = {"content_type": "text/plain", "text": "Hello"}
summary_obj = common_utils.attach_content(user.actor, "summary_obj", summary) summary_obj = common_utils.attach_content(user.actor, "summary_obj", summary)
avatar = factories["common.Attachment"]()
user.actor.attachment_icon = avatar
user.actor.save()
api_client.login(username=user.username, password="test") api_client.login(username=user.username, password="test")
response = api_client.get(url) response = api_client.get(url)
assert response.status_code == 200 assert response.status_code == 200
@ -120,6 +122,9 @@ def test_can_fetch_data_from_api(api_client, factories):
assert response.data["email"] == user.email assert response.data["email"] == user.email
assert response.data["name"] == user.name assert response.data["name"] == user.name
assert response.data["permissions"] == user.get_permissions() assert response.data["permissions"] == user.get_permissions()
assert (
response.data["avatar"] == common_serializers.AttachmentSerializer(avatar).data
)
assert ( assert (
response.data["summary"] response.data["summary"]
== common_serializers.ContentSerializer(summary_obj).data == common_serializers.ContentSerializer(summary_obj).data
@ -301,18 +306,18 @@ def test_user_cannot_patch_another_user(method, logged_in_api_client, factories)
assert response.status_code == 403 assert response.status_code == 403
def test_user_can_patch_their_own_avatar(logged_in_api_client, avatar): def test_user_can_patch_their_own_avatar(logged_in_api_client, factories):
user = logged_in_api_client.user user = logged_in_api_client.user
actor = user.create_actor()
attachment = factories["common.Attachment"](actor=actor)
url = reverse("api:v1:users:users-detail", kwargs={"username": user.username}) url = reverse("api:v1:users:users-detail", kwargs={"username": user.username})
content = avatar.read() payload = {"avatar": attachment.uuid}
avatar.seek(0)
payload = {"avatar": avatar}
response = logged_in_api_client.patch(url, payload) response = logged_in_api_client.patch(url, payload)
assert response.status_code == 200 assert response.status_code == 200
user.refresh_from_db() user.refresh_from_db()
assert user.avatar.read() == content assert user.actor.attachment_icon == attachment
def test_creating_user_creates_actor_as_well( def test_creating_user_creates_actor_as_well(

View File

@ -74,7 +74,7 @@
</router-link> </router-link>
<div class="item"> <div class="item">
<div class="ui user-dropdown dropdown" > <div class="ui user-dropdown dropdown" >
<img class="ui avatar image" v-if="$store.state.auth.profile.avatar.square_crop" v-lazy="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" /> <img class="ui avatar image" v-if="$store.state.auth.profile.avatar.square_crop" :src="$store.getters['instance/absoluteUrl']($store.state.auth.profile.avatar.square_crop)" />
<actor-avatar v-else :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username}" /> <actor-avatar v-else :actor="{preferred_username: $store.state.auth.username, full_username: $store.state.auth.username}" />
<div class="menu"> <div class="menu">
<router-link class="item" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link> <router-link class="item" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><translate translate-context="*/*/*/Noun">Profile</translate></router-link>

View File

@ -41,25 +41,13 @@
<li v-for="error in avatarErrors">{{ error }}</li> <li v-for="error in avatarErrors">{{ error }}</li>
</ul> </ul>
</div> </div>
<div class="ui stackable grid"> {{ }}
<div class="ui ten wide column"> <attachment-input
<h3 class="ui header"><translate translate-context="Content/Settings/Title/Verb">Upload a new avatar</translate></h3> :value="avatar.uuid"
<p><translate translate-context="Content/Settings/Paragraph">PNG, GIF or JPG. At most 2MB. Will be downscaled to 400x400px.</translate></p> @input="submitAvatar($event)"
<input class="ui input" ref="avatar" type="file" /> :initial-value="initialAvatar"
<div class="ui hidden divider"></div> :required="false"
<button @click="submitAvatar" :class="['ui', {'loading': isLoadingAvatar}, 'button']"> @delete="avatar = {uuid: null}"></attachment-input>
<translate translate-context="Content/Settings/Button.Label/Verb">Update avatar</translate>
</button>
</div>
<div class="ui six wide column">
<h3 class="ui header"><translate translate-context="Content/Settings/Title/Noun">Current avatar</translate></h3>
<img class="ui circular image" v-if="currentAvatar && currentAvatar.square_crop" v-lazy="$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 translate-context="Content/Settings/Button.Label/Verb">Remove avatar</translate>
</button>
</div>
</div>
</div> </div>
</section> </section>
@ -315,12 +303,14 @@ import logger from "@/logging"
import PasswordInput from "@/components/forms/PasswordInput" import PasswordInput from "@/components/forms/PasswordInput"
import SubsonicTokenForm from "@/components/auth/SubsonicTokenForm" import SubsonicTokenForm from "@/components/auth/SubsonicTokenForm"
import TranslationsMixin from "@/components/mixins/Translations" import TranslationsMixin from "@/components/mixins/Translations"
import AttachmentInput from '@/components/common/AttachmentInput'
export default { export default {
mixins: [TranslationsMixin], mixins: [TranslationsMixin],
components: { components: {
PasswordInput, PasswordInput,
SubsonicTokenForm SubsonicTokenForm,
AttachmentInput
}, },
data() { data() {
let d = { let d = {
@ -328,7 +318,7 @@ export default {
// properties that will be used in it // properties that will be used in it
old_password: "", old_password: "",
new_password: "", new_password: "",
currentAvatar: this.$store.state.auth.profile.avatar, avatar: {...(this.$store.state.auth.profile.avatar || {uuid: null})},
passwordError: "", passwordError: "",
password: "", password: "",
isLoading: false, isLoading: false,
@ -336,7 +326,6 @@ export default {
isDeletingAccount: false, isDeletingAccount: false,
accountDeleteErrors: [], accountDeleteErrors: [],
avatarErrors: [], avatarErrors: [],
avatar: null,
apps: [], apps: [],
ownedApps: [], ownedApps: [],
settings: { settings: {
@ -352,6 +341,7 @@ export default {
} }
} }
} }
d.initialAvatar = d.avatar.uuid
d.settings.order.forEach(id => { d.settings.order.forEach(id => {
d.settings.fields[id].value = d.settings.fields[id].initial d.settings.fields[id].value = d.settings.fields[id].initial
d.settings.fields[id].id = id d.settings.fields[id].id = id
@ -437,44 +427,17 @@ export default {
} }
) )
}, },
submitAvatar() { submitAvatar(uuid) {
this.isLoadingAvatar = true this.isLoadingAvatar = true
this.avatarErrors = [] this.avatarErrors = []
let self = this let self = this
this.avatar = this.$refs.avatar.files[0]
let formData = new FormData()
formData.append("avatar", this.avatar)
axios axios
.patch(`users/users/${this.$store.state.auth.username}/`, formData, { .patch(`users/users/${this.$store.state.auth.username}/`, {avatar: uuid})
headers: {
"Content-Type": "multipart/form-data"
}
})
.then( .then(
response => { response => {
this.isLoadingAvatar = false this.isLoadingAvatar = false
self.currentAvatar = response.data.avatar self.avatar = response.data.avatar
self.$store.commit("auth/avatar", self.currentAvatar) self.$store.commit("auth/avatar", response.data.avatar)
},
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 => { error => {
self.isLoadingAvatar = false self.isLoadingAvatar = false