From 1cd680526a9073b9780d8bf2f963f2883df33a7e Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 20 Nov 2020 16:35:48 +0100
Subject: [PATCH 1/8] Add backend to remove pictures

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 lib/graphql/resolvers/picture.ex        |  30 +++-
 lib/graphql/schema/picture.ex           |  10 +-
 test/graphql/resolvers/picture_test.exs | 178 +++++++++++++++---------
 3 files changed, 144 insertions(+), 74 deletions(-)

diff --git a/lib/graphql/resolvers/picture.ex b/lib/graphql/resolvers/picture.ex
index a5b26e13f..9a17e22ef 100644
--- a/lib/graphql/resolvers/picture.ex
+++ b/lib/graphql/resolvers/picture.ex
@@ -6,6 +6,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
   alias Mobilizon.Actors.Actor
   alias Mobilizon.{Media, Users}
   alias Mobilizon.Media.Picture
+  alias Mobilizon.Users.User
   import Mobilizon.Web.Gettext
 
   @doc """
@@ -37,8 +38,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
            size: file.size
          }}
 
-      _error ->
-        {:error, dgettext("errors", "Picture with ID %{id} was not found", id: picture_id)}
+      nil ->
+        {:error, :not_found}
     end
   end
 
@@ -46,7 +47,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
   def upload_picture(
         _parent,
         %{file: %Plug.Upload{} = file} = args,
-        %{context: %{current_user: user}}
+        %{context: %{current_user: %User{} = user}}
       ) do
     with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
          {:ok, %{name: _name, url: url, content_type: content_type, size: size}} <-
@@ -75,7 +76,26 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
     end
   end
 
-  def upload_picture(_parent, _args, _resolution) do
-    {:error, dgettext("errors", "You need to login to upload a picture")}
+  def upload_picture(_parent, _args, _resolution), do: {:error, :unauthenticated}
+
+  @doc """
+  Remove a picture that the user owns
+  """
+  @spec remove_picture(map(), map(), map()) ::
+          {:ok, Picture.t()}
+          | {:error, :unauthorized}
+          | {:error, :unauthenticated}
+          | {:error, :not_found}
+  def remove_picture(_parent, %{id: picture_id}, %{context: %{current_user: %User{} = user}}) do
+    with {:picture, %Picture{actor_id: actor_id} = picture} <-
+           {:picture, Media.get_picture(picture_id)},
+         {:is_owned, %Actor{} = _actor} <- User.owns_actor(user, actor_id) do
+      Media.delete_picture(picture)
+    else
+      {:picture, nil} -> {:error, :not_found}
+      {:is_owned, _} -> {:error, :unauthorized}
+    end
   end
+
+  def remove_picture(_parent, _args, _resolution), do: {:error, :unauthenticated}
 end
diff --git a/lib/graphql/schema/picture.ex b/lib/graphql/schema/picture.ex
index 401664b62..02f76a96d 100644
--- a/lib/graphql/schema/picture.ex
+++ b/lib/graphql/schema/picture.ex
@@ -35,7 +35,7 @@ defmodule Mobilizon.GraphQL.Schema.PictureType do
   object :picture_queries do
     @desc "Get a picture"
     field :picture, :picture do
-      arg(:id, non_null(:string), description: "The picture ID")
+      arg(:id, non_null(:id), description: "The picture ID")
       resolve(&Picture.picture/3)
     end
   end
@@ -48,5 +48,13 @@ defmodule Mobilizon.GraphQL.Schema.PictureType do
       arg(:file, non_null(:upload), description: "The picture file")
       resolve(&Picture.upload_picture/3)
     end
+
+    @desc """
+    Remove a picture
+    """
+    field :remove_picture, :deleted_object do
+      arg(:id, non_null(:id), description: "The picture's ID")
+      resolve(&Picture.remove_picture/3)
+    end
   end
 end
diff --git a/test/graphql/resolvers/picture_test.exs b/test/graphql/resolvers/picture_test.exs
index 6343bb906..620335a45 100644
--- a/test/graphql/resolvers/picture_test.exs
+++ b/test/graphql/resolvers/picture_test.exs
@@ -17,76 +17,69 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
     {:ok, conn: conn, user: user, actor: actor}
   end
 
+  @picture_query """
+  query Picture($id: ID!) {
+    picture(id: $id) {
+      id
+      name,
+      alt,
+      url,
+      content_type,
+      size
+    }
+  }
+  """
+
   describe "Resolver: Get picture" do
-    test "picture/3 returns the information on a picture", context do
+    test "picture/3 returns the information on a picture", %{conn: conn} do
       %Picture{id: id} = picture = insert(:picture)
 
-      query = """
-      {
-        picture(id: "#{id}") {
-            name,
-            alt,
-            url,
-            content_type,
-            size
-        }
-      }
-      """
-
       res =
-        context.conn
-        |> get("/api", AbsintheHelpers.query_skeleton(query, "picture"))
+        conn
+        |> AbsintheHelpers.graphql_query(query: @picture_query, variables: %{id: id})
 
-      assert json_response(res, 200)["data"]["picture"]["name"] == picture.file.name
+      assert res["data"]["picture"]["name"] == picture.file.name
 
-      assert json_response(res, 200)["data"]["picture"]["content_type"] ==
+      assert res["data"]["picture"]["content_type"] ==
                picture.file.content_type
 
-      assert json_response(res, 200)["data"]["picture"]["size"] == 13_120
+      assert res["data"]["picture"]["size"] == 13_120
 
-      assert json_response(res, 200)["data"]["picture"]["url"] =~ Endpoint.url()
+      assert res["data"]["picture"]["url"] =~ Endpoint.url()
     end
 
-    test "picture/3 returns nothing on a non-existent picture", context do
-      query = """
-      {
-        picture(id: "3") {
-            name,
-            alt,
-            url
-        }
-      }
-      """
-
+    test "picture/3 returns nothing on a non-existent picture", %{conn: conn} do
       res =
-        context.conn
-        |> get("/api", AbsintheHelpers.query_skeleton(query, "picture"))
+        conn
+        |> AbsintheHelpers.graphql_query(query: @picture_query, variables: %{id: 3})
 
-      assert hd(json_response(res, 200)["errors"])["message"] ==
-               "Picture with ID 3 was not found"
+      assert hd(res["errors"])["message"] == "Resource not found"
+      assert hd(res["errors"])["status_code"] == 404
     end
   end
 
   describe "Resolver: Upload picture" do
+    @upload_picture_mutation """
+    mutation UploadPicture($name: String!, $alt: String, $file: Upload!) {
+      uploadPicture(
+        name: $name
+        alt: $alt
+        file: $file
+      ) {
+          url
+          name
+          content_type
+          size
+      }
+    }
+    """
+
     test "upload_picture/3 uploads a new picture", %{conn: conn, user: user} do
       picture = %{name: "my pic", alt: "represents something", file: "picture.png"}
 
-      mutation = """
-      mutation { uploadPicture(
-              name: "#{picture.name}",
-              alt: "#{picture.alt}",
-              file: "#{picture.file}"
-            ) {
-                url,
-                name,
-                content_type,
-                size
-              }
-        }
-      """
-
       map = %{
-        "query" => mutation,
+        "query" => @upload_picture_mutation,
+        "variables" => picture,
         picture.file => %Plug.Upload{
           path: "test/fixtures/picture.png",
           filename: picture.file
@@ -101,30 +94,20 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
           "/api",
           map
         )
+        |> json_response(200)
 
-      assert json_response(res, 200)["data"]["uploadPicture"]["name"] == picture.name
-      assert json_response(res, 200)["data"]["uploadPicture"]["content_type"] == "image/png"
-      assert json_response(res, 200)["data"]["uploadPicture"]["size"] == 10_097
-      assert json_response(res, 200)["data"]["uploadPicture"]["url"]
+      assert res["data"]["uploadPicture"]["name"] == picture.name
+      assert res["data"]["uploadPicture"]["content_type"] == "image/png"
+      assert res["data"]["uploadPicture"]["size"] == 10_097
+      assert res["data"]["uploadPicture"]["url"]
     end
 
     test "upload_picture/3 forbids uploading if no auth", %{conn: conn} do
       picture = %{name: "my pic", alt: "represents something", file: "picture.png"}
 
-      mutation = """
-      mutation { uploadPicture(
-              name: "#{picture.name}",
-              alt: "#{picture.alt}",
-              file: "#{picture.file}"
-            ) {
-                url,
-                name
-              }
-        }
-      """
-
       map = %{
-        "query" => mutation,
+        "query" => @upload_picture_mutation,
+        "variables" => picture,
         picture.file => %Plug.Upload{
           path: "test/fixtures/picture.png",
           filename: picture.file
@@ -138,9 +121,68 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
           "/api",
           map
         )
+        |> json_response(200)
 
-      assert hd(json_response(res, 200)["errors"])["message"] ==
-               "You need to login to upload a picture"
+      assert hd(res["errors"])["message"] == "You need to be logged in"
+    end
+  end
+
+  describe "Resolver: Remove picture" do
+    @remove_picture_mutation """
+    mutation RemovePicture($id: ID!) {
+      removePicture(id: $id) {
+        id
+      }
+    }
+    """
+
+    test "Removes a previously uploaded picture", %{conn: conn, user: user, actor: actor} do
+      %Picture{id: picture_id} = insert(:picture, actor: actor)
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(
+          query: @remove_picture_mutation,
+          variables: %{id: picture_id}
+        )
+
+      assert is_nil(res["errors"])
+      assert res["data"]["removePicture"]["id"] == to_string(picture_id)
+
+      res =
+        conn
+        |> AbsintheHelpers.graphql_query(query: @picture_query, variables: %{id: picture_id})
+
+      assert hd(res["errors"])["message"] == "Resource not found"
+      assert hd(res["errors"])["status_code"] == 404
+    end
+
+    test "Removes nothing if picture is not found", %{conn: conn, user: user} do
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(
+          query: @remove_picture_mutation,
+          variables: %{id: 400}
+        )
+
+      assert hd(res["errors"])["message"] == "Resource not found"
+      assert hd(res["errors"])["status_code"] == 404
+    end
+
+    test "Removes nothing if picture if not logged-in", %{conn: conn, actor: actor} do
+      %Picture{id: picture_id} = insert(:picture, actor: actor)
+
+      res =
+        conn
+        |> AbsintheHelpers.graphql_query(
+          query: @remove_picture_mutation,
+          variables: %{id: picture_id}
+        )
+
+      assert hd(res["errors"])["message"] == "You need to be logged in"
+      assert hd(res["errors"])["status_code"] == 401
     end
   end
 end

From 7a731f1ef8bf2dd6d6ed68940d3808ed1998043a Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 20 Nov 2020 18:06:57 +0100
Subject: [PATCH 2/8] Fix pictures being deleting cascading to events & posts

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 .../20201120161229_fix_picture_deletion.exs   | 31 +++++++++++++++++++
 1 file changed, 31 insertions(+)
 create mode 100644 priv/repo/migrations/20201120161229_fix_picture_deletion.exs

diff --git a/priv/repo/migrations/20201120161229_fix_picture_deletion.exs b/priv/repo/migrations/20201120161229_fix_picture_deletion.exs
new file mode 100644
index 000000000..a0ad37da5
--- /dev/null
+++ b/priv/repo/migrations/20201120161229_fix_picture_deletion.exs
@@ -0,0 +1,31 @@
+defmodule Mobilizon.Storage.Repo.Migrations.FixPictureDeletion do
+  use Ecto.Migration
+
+  def up do
+    drop_if_exists(constraint(:posts, "posts_picture_id_fkey"))
+
+    alter table(:posts) do
+      modify(:picture_id, references(:pictures, on_delete: :nilify_all))
+    end
+
+    drop_if_exists(constraint(:events, "events_picture_id_fkey"))
+
+    alter table(:events) do
+      modify(:picture_id, references(:pictures, on_delete: :nilify_all))
+    end
+  end
+
+  def down do
+    drop_if_exists(constraint(:posts, "posts_picture_id_fkey"))
+
+    alter table(:posts) do
+      modify(:picture_id, references(:pictures, on_delete: :delete_all))
+    end
+
+    drop_if_exists(constraint(:events, "events_picture_id_fkey"))
+
+    alter table(:events) do
+      modify(:picture_id, references(:pictures, on_delete: :delete_all))
+    end
+  end
+end

From 605239130e221936eafdb7ce0b99dab66c28f8d8 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 20 Nov 2020 18:34:13 +0100
Subject: [PATCH 3/8] Refactor Picture upload

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/src/components/PictureUpload.vue           | 59 +++++++++++++------
 js/src/graphql/upload.ts                      |  9 ++-
 js/src/types/event.model.ts                   |  3 +-
 js/src/utils/image.ts                         |  2 +-
 .../views/Account/children/EditIdentity.vue   | 13 +---
 js/src/views/Event/Edit.vue                   | 15 +++--
 js/src/views/Group/GroupSettings.vue          |  6 +-
 js/src/views/Posts/Edit.vue                   |  2 +-
 8 files changed, 70 insertions(+), 39 deletions(-)

diff --git a/js/src/components/PictureUpload.vue b/js/src/components/PictureUpload.vue
index 2fb40a9e7..64843b69d 100644
--- a/js/src/components/PictureUpload.vue
+++ b/js/src/components/PictureUpload.vue
@@ -1,7 +1,7 @@
 <template>
   <div class="root">
-    <figure class="image" v-if="actualImageSrc">
-      <img :src="actualImageSrc" />
+    <figure class="image" v-if="imageSrc">
+      <img :src="imageSrc" />
     </figure>
     <figure class="image is-128x128" v-else>
       <div class="image-placeholder">
@@ -9,12 +9,19 @@
       </div>
     </figure>
 
-    <b-upload @input="onFileChanged" :accept="accept">
-      <a class="button is-primary">
-        <b-icon icon="upload"></b-icon>
-        <span>{{ $t("Click to upload") }}</span>
-      </a>
-    </b-upload>
+    <div class="action-buttons">
+      <b-field class="file is-primary">
+        <b-upload @input="onFileChanged" :accept="accept" class="file-label">
+          <span class="file-cta">
+            <b-icon class="file-icon" icon="upload" />
+            <span>{{ $t("Click to upload") }}</span>
+          </span>
+        </b-upload>
+      </b-field>
+      <b-button type="is-text" v-if="imageSrc" @click="removeOrClearPicture">
+        {{ $t("Clear") }}
+      </b-button>
+    </div>
   </div>
 </template>
 
@@ -45,16 +52,22 @@ figure.image {
     color: #eee;
   }
 }
+
+.action-buttons {
+  display: flex;
+  flex-direction: column;
+}
 </style>
 
 <script lang="ts">
+import { IPicture } from "@/types/picture.model";
 import { Component, Model, Prop, Vue, Watch } from "vue-property-decorator";
 
 @Component
 export default class PictureUpload extends Vue {
   @Model("change", { type: File }) readonly pictureFile!: File;
 
-  @Prop({ type: String, required: false }) defaultImageSrc!: string;
+  @Prop({ type: Object, required: false }) defaultImage!: IPicture;
 
   @Prop({ type: String, required: false, default: "image/gif,image/png,image/jpeg,image/webp" })
   accept!: string;
@@ -70,24 +83,40 @@ export default class PictureUpload extends Vue {
   })
   textFallback!: string;
 
-  imageSrc: string | null = null;
+  imageSrc: string | null = this.defaultImage ? this.defaultImage.url : null;
+
+  file!: File | null;
 
   mounted(): void {
-    this.updatePreview(this.pictureFile);
+    if (this.pictureFile) {
+      this.updatePreview(this.pictureFile);
+    }
   }
 
   @Watch("pictureFile")
   onPictureFileChanged(val: File): void {
+    console.log("onPictureFileChanged", val);
     this.updatePreview(val);
   }
 
-  onFileChanged(file: File): void {
+  @Watch("defaultImage")
+  onDefaultImageChange(defaultImage: IPicture): void {
+    console.log("onDefaultImageChange", defaultImage);
+    this.imageSrc = defaultImage ? defaultImage.url : null;
+  }
+
+  onFileChanged(file: File | null): void {
     this.$emit("change", file);
 
     this.updatePreview(file);
+    this.file = file;
   }
 
-  private updatePreview(file?: File) {
+  async removeOrClearPicture(): Promise<void> {
+    this.onFileChanged(null);
+  }
+
+  private updatePreview(file?: File | null) {
     if (file) {
       this.imageSrc = URL.createObjectURL(file);
       return;
@@ -95,9 +124,5 @@ export default class PictureUpload extends Vue {
 
     this.imageSrc = null;
   }
-
-  get actualImageSrc(): string | null {
-    return this.imageSrc || this.defaultImageSrc;
-  }
 }
 </script>
diff --git a/js/src/graphql/upload.ts b/js/src/graphql/upload.ts
index b1d1cafb9..6d011cd1d 100644
--- a/js/src/graphql/upload.ts
+++ b/js/src/graphql/upload.ts
@@ -1,6 +1,5 @@
 import gql from "graphql-tag";
 
-/* eslint-disable import/prefer-default-export */
 export const UPLOAD_PICTURE = gql`
   mutation UploadPicture($file: Upload!, $alt: String, $name: String!) {
     uploadPicture(file: $file, alt: $alt, name: $name) {
@@ -9,3 +8,11 @@ export const UPLOAD_PICTURE = gql`
     }
   }
 `;
+
+export const REMOVE_PICTURE = gql`
+  mutation RemovePicture($id: ID!) {
+    removePicture(id: $id) {
+      id
+    }
+  }
+`;
diff --git a/js/src/types/event.model.ts b/js/src/types/event.model.ts
index e9bedbef6..133a6f88c 100644
--- a/js/src/types/event.model.ts
+++ b/js/src/types/event.model.ts
@@ -69,7 +69,7 @@ interface IEventEditJSON {
   visibility: EventVisibility;
   joinOptions: EventJoinOptions;
   draft: boolean;
-  picture: IPicture | { pictureId: string } | null;
+  picture?: IPicture | { pictureId: string } | null;
   attributedToId: string | null;
   onlineAddress?: string;
   phoneAddress?: string;
@@ -234,7 +234,6 @@ export class EventModel implements IEvent {
       joinOptions: this.joinOptions,
       draft: this.draft,
       tags: this.tags.map((t) => t.title),
-      picture: this.picture,
       onlineAddress: this.onlineAddress,
       phoneAddress: this.phoneAddress,
       physicalAddress: this.physicalAddress,
diff --git a/js/src/utils/image.ts b/js/src/utils/image.ts
index 5c817168e..304b7e4b9 100644
--- a/js/src/utils/image.ts
+++ b/js/src/utils/image.ts
@@ -9,7 +9,7 @@ export async function buildFileFromIPicture(obj: IPicture | null | undefined): P
   return new File([blob], obj.name);
 }
 
-export function buildFileVariable<T>(file: File | null, name: string, alt?: string): Record<string, unknown> {
+export function buildFileVariable(file: File | null, name: string, alt?: string): Record<string, unknown> {
   if (!file) return {};
 
   return {
diff --git a/js/src/views/Account/children/EditIdentity.vue b/js/src/views/Account/children/EditIdentity.vue
index 6054c1e22..ddedce7b0 100644
--- a/js/src/views/Account/children/EditIdentity.vue
+++ b/js/src/views/Account/children/EditIdentity.vue
@@ -27,7 +27,7 @@
         <span v-else>{{ $t("I create an identity") }}</span>
       </h1>
 
-      <picture-upload v-model="avatarFile" :defaultImageSrc="avatarUrl" class="picture-upload" />
+      <picture-upload v-model="avatarFile" :defaultImage="identity.avatar" class="picture-upload" />
 
       <b-field horizontal :label="$t('Display name')">
         <b-input
@@ -124,6 +124,7 @@ h1 {
 <script lang="ts">
 import { Component, Prop, Watch } from "vue-property-decorator";
 import { mixins } from "vue-class-component";
+import { IPicture } from "@/types/picture.model";
 import {
   CREATE_PERSON,
   CURRENT_ACTOR_CLIENT,
@@ -136,7 +137,7 @@ import { IPerson, Person } from "../../../types/actor";
 import PictureUpload from "../../../components/PictureUpload.vue";
 import { MOBILIZON_INSTANCE_HOST } from "../../../api/_entrypoint";
 import RouteName from "../../../router/name";
-import { buildFileVariable } from "../../../utils/image";
+import { buildFileFromIPicture, buildFileVariable } from "../../../utils/image";
 import { changeIdentity } from "../../../utils/auth";
 import identityEditionMixin from "../../../mixins/identityEdition";
 
@@ -186,13 +187,6 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
     ) as string;
   }
 
-  get avatarUrl(): string | null {
-    if (this.identity && this.identity.avatar && this.identity.avatar.url) {
-      return this.identity.avatar.url;
-    }
-    return null;
-  }
-
   @Watch("isUpdate")
   async isUpdateChanged(): Promise<void> {
     this.resetFields();
@@ -286,7 +280,6 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
           }
         },
       });
-      this.avatarFile = null;
 
       this.$notifier.success(
         this.$t("Identity {displayName} updated", {
diff --git a/js/src/views/Event/Edit.vue b/js/src/views/Event/Edit.vue
index c2c7af277..d9bd96fb5 100644
--- a/js/src/views/Event/Edit.vue
+++ b/js/src/views/Event/Edit.vue
@@ -10,7 +10,11 @@
 
       <form ref="form">
         <subtitle>{{ $t("General information") }}</subtitle>
-        <picture-upload v-model="pictureFile" :textFallback="$t('Headline picture')" />
+        <picture-upload
+          v-model="pictureFile"
+          :textFallback="$t('Headline picture')"
+          :defaultImage="event.picture"
+        />
 
         <b-field :label="$t('Title')" :type="checkTitleLength[0]" :message="checkTitleLength[1]">
           <b-input size="is-large" aria-required="true" required v-model="event.title" />
@@ -676,6 +680,7 @@ export default class EditEvent extends Vue {
             __typename: "Person",
             id: organizerActor.id,
             participations: {
+              __typename: "PaginatedParticipantList",
               total: 1,
               elements: [
                 {
@@ -763,11 +768,13 @@ export default class EditEvent extends Vue {
       res.endsOn = null;
     }
 
-    const pictureObj = buildFileVariable(this.pictureFile, "picture");
-    res = { ...res, ...pictureObj };
+    if (this.pictureFile) {
+      const pictureObj = buildFileVariable(this.pictureFile, "picture");
+      res = { ...res, ...pictureObj };
+    }
 
     try {
-      if (this.event.picture) {
+      if (this.event.picture && this.pictureFile) {
         const oldPictureFile = (await buildFileFromIPicture(this.event.picture)) as File;
         const oldPictureFileContent = await readFileAsync(oldPictureFile);
         const newPictureFileContent = await readFileAsync(this.pictureFile as File);
diff --git a/js/src/views/Group/GroupSettings.vue b/js/src/views/Group/GroupSettings.vue
index 58bf11569..867b43db4 100644
--- a/js/src/views/Group/GroupSettings.vue
+++ b/js/src/views/Group/GroupSettings.vue
@@ -31,7 +31,7 @@
         </li>
       </ul>
     </nav>
-    <section class="container section" v-if="isCurrentActorAGroupAdmin">
+    <section class="container section" v-if="group && isCurrentActorAGroupAdmin">
       <form @submit.prevent="updateGroup">
         <b-field :label="$t('Group name')">
           <b-input v-model="group.name" />
@@ -43,7 +43,7 @@
           <picture-upload
             :textFallback="$t('Avatar')"
             v-model="avatarFile"
-            :defaultImageSrc="group.avatar ? group.avatar.url : null"
+            :defaultImage="group.avatar"
           />
         </b-field>
 
@@ -51,7 +51,7 @@
           <picture-upload
             :textFallback="$t('Banner')"
             v-model="bannerFile"
-            :defaultImageSrc="group.banner ? group.banner.url : null"
+            :defaultImage="group.banner"
           />
         </b-field>
         <p class="label">{{ $t("Group visibility") }}</p>
diff --git a/js/src/views/Posts/Edit.vue b/js/src/views/Posts/Edit.vue
index 32974e4a1..f11f3d6b3 100644
--- a/js/src/views/Posts/Edit.vue
+++ b/js/src/views/Posts/Edit.vue
@@ -12,7 +12,7 @@
         <picture-upload
           v-model="pictureFile"
           :textFallback="$t('Headline picture')"
-          :defaultImageSrc="post.picture ? post.picture.url : null"
+          :defaultImage="post.picture"
         />
 
         <b-field

From 01b117683831f983fbb53a3f47a9d8427ed81104 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 20 Nov 2020 19:01:42 +0100
Subject: [PATCH 4/8] Fix some bad french translations

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 priv/gettext/fr/LC_MESSAGES/errors.po | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/priv/gettext/fr/LC_MESSAGES/errors.po b/priv/gettext/fr/LC_MESSAGES/errors.po
index fa7f44527..4351208c2 100644
--- a/priv/gettext/fr/LC_MESSAGES/errors.po
+++ b/priv/gettext/fr/LC_MESSAGES/errors.po
@@ -609,22 +609,22 @@ msgstr "Vous n'avez pas la permission de supprimer ce jeton"
 #, elixir-format
 #: lib/graphql/resolvers/admin.ex:52
 msgid "You need to be logged-in and a moderator to list action logs"
-msgstr "Vous devez être connecté·e pour rejoindre un groupe"
+msgstr "Vous devez être connecté·e et une modérateur·ice pour lister les journaux de modération"
 
 #, elixir-format
 #: lib/graphql/resolvers/report.ex:26
 msgid "You need to be logged-in and a moderator to list reports"
-msgstr "Vous devez être connecté·e pour rejoindre un groupe"
+msgstr "Vous devez être connecté·e et une modérateur·ice pour lister les signalements"
 
 #, elixir-format
 #: lib/graphql/resolvers/report.ex:101
 msgid "You need to be logged-in and a moderator to update a report"
-msgstr "Vous devez être connecté·e pour supprimer un groupe"
+msgstr "Vous devez être connecté·e et une modérateur·ice pour modifier un signalement"
 
 #, elixir-format
 #: lib/graphql/resolvers/report.ex:41
 msgid "You need to be logged-in and a moderator to view a report"
-msgstr "Vous devez être connecté·e pour rejoindre un groupe"
+msgstr "Vous devez être connecté·e pour et une modérateur·ice pour visionner un signalement"
 
 #, elixir-format
 #: lib/graphql/resolvers/admin.ex:236
@@ -689,7 +689,7 @@ msgstr "Vous devez être connecté·e pour supprimer un groupe"
 #, elixir-format
 #: lib/graphql/resolvers/participant.ex:105
 msgid "You need to be logged-in to join an event"
-msgstr "Vous devez être connecté·e pour rejoindre un groupe"
+msgstr "Vous devez être connecté·e pour rejoindre un événement"
 
 #, elixir-format
 #: lib/graphql/resolvers/participant.ex:204

From 846f7b71f39cbcdc11286b82f92ea75d10186f98 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Fri, 20 Nov 2020 19:27:40 +0100
Subject: [PATCH 5/8] Update some outdated dev config

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 config/dev.exs | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/config/dev.exs b/config/dev.exs
index a58b0c969..97fb3b585 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -19,7 +19,6 @@ config :mobilizon, Mobilizon.Web.Endpoint,
   code_reloader: true,
   check_origin: false,
   watchers: [
-    # yarn: ["run", "dev", cd: Path.expand("../js", __DIR__)]
     node: [
       "node_modules/webpack/bin/webpack.js",
       "--mode",
@@ -53,8 +52,8 @@ config :mobilizon, Mobilizon.Web.Endpoint,
     patterns: [
       ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
       ~r{priv/gettext/.*(po)$},
-      ~r{lib/mobilizon_web/views/.*(ex)$},
-      ~r{lib/mobilizon_web/templates/.*(eex)$}
+      ~r{lib/web/(live|views)/.*(ex)$},
+      ~r{lib/web/templates/.*(eex)$}
     ]
   ]
 

From 6a1cd42d2c27cd2f6b6522251ed381eb5693c7cd Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Mon, 23 Nov 2020 10:38:01 +0100
Subject: [PATCH 6/8] Add backend to list an user's pictures

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 lib/graphql/resolvers/user.ex | 22 ++++++++++++++++++++++
 lib/graphql/schema/picture.ex |  8 ++++++++
 lib/graphql/schema/post.ex    |  2 +-
 lib/graphql/schema/user.ex    | 10 ++++++++++
 lib/mobilizon/media/media.ex  | 22 +++++++++++++++++++++-
 5 files changed, 62 insertions(+), 2 deletions(-)

diff --git a/lib/graphql/resolvers/user.ex b/lib/graphql/resolvers/user.ex
index fef857307..bcd283b9d 100644
--- a/lib/graphql/resolvers/user.ex
+++ b/lib/graphql/resolvers/user.ex
@@ -525,6 +525,28 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
     end
   end
 
+  def user_medias(%User{id: user_id}, %{page: page, limit: limit}, %{
+        context: %{current_user: %User{id: logged_in_user_id}}
+      })
+      when user_id == logged_in_user_id do
+    %{elements: elements, total: total} = Mobilizon.Media.pictures_for_user(user_id, page, limit)
+
+    {:ok,
+     %{
+       elements:
+         Enum.map(elements, fn element ->
+           %{
+             name: element.file.name,
+             url: element.file.url,
+             id: element.id,
+             content_type: element.file.content_type,
+             size: element.file.size
+           }
+         end),
+       total: total
+     }}
+  end
+
   @spec update_user_login_information(User.t(), map()) ::
           {:ok, User.t()} | {:error, Ecto.Changeset.t()}
   defp update_user_login_information(
diff --git a/lib/graphql/schema/picture.ex b/lib/graphql/schema/picture.ex
index 02f76a96d..f1fde46d2 100644
--- a/lib/graphql/schema/picture.ex
+++ b/lib/graphql/schema/picture.ex
@@ -16,6 +16,14 @@ defmodule Mobilizon.GraphQL.Schema.PictureType do
     field(:size, :integer, description: "The picture's size")
   end
 
+  @desc """
+  A paginated list of pictures
+  """
+  object :paginated_picture_list do
+    field(:elements, list_of(:picture), description: "The list of pictures")
+    field(:total, :integer, description: "The total number of pictures in the list")
+  end
+
   @desc "An attached picture or a link to a picture"
   input_object :picture_input do
     # Either a full picture object
diff --git a/lib/graphql/schema/post.ex b/lib/graphql/schema/post.ex
index e796e620c..b3af7764e 100644
--- a/lib/graphql/schema/post.ex
+++ b/lib/graphql/schema/post.ex
@@ -26,7 +26,7 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
     )
 
     field(:picture, :picture,
-      description: "The event's picture",
+      description: "The posts's picture",
       resolve: &Picture.picture/3
     )
   end
diff --git a/lib/graphql/schema/user.ex b/lib/graphql/schema/user.ex
index 4986aa10c..668a9c1a6 100644
--- a/lib/graphql/schema/user.ex
+++ b/lib/graphql/schema/user.ex
@@ -110,6 +110,16 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
     field(:current_sign_in_ip, :string,
       description: "The IP adress the user's currently signed-in with"
     )
+
+    field(:media, :paginated_picture_list, description: "The user's media objects") do
+      arg(:page, :integer,
+        default_value: 1,
+        description: "The page in the paginated user media list"
+      )
+
+      arg(:limit, :integer, default_value: 10, description: "The limit of user media per page")
+      resolve(&User.user_medias/3)
+    end
   end
 
   @desc "The list of roles an user can have"
diff --git a/lib/mobilizon/media/media.ex b/lib/mobilizon/media/media.ex
index 549f84bd6..3af6665f1 100644
--- a/lib/mobilizon/media/media.ex
+++ b/lib/mobilizon/media/media.ex
@@ -7,8 +7,10 @@ defmodule Mobilizon.Media do
 
   alias Ecto.Multi
 
+  alias Mobilizon.Actors.Actor
   alias Mobilizon.Media.{File, Picture}
-  alias Mobilizon.Storage.Repo
+  alias Mobilizon.Storage.{Page, Repo}
+  alias Mobilizon.Users.User
 
   alias Mobilizon.Web.Upload
 
@@ -35,6 +37,16 @@ defmodule Mobilizon.Media do
     |> Repo.one()
   end
 
+  @doc """
+  List the paginated picture for user
+  """
+  @spec pictures_for_user(integer | String.t(), integer | nil, integer | nil) :: Page.t()
+  def pictures_for_user(user_id, page, limit) do
+    user_id
+    |> pictures_for_user_query()
+    |> Page.build_page(page, limit)
+  end
+
   @doc """
   Creates a picture.
   """
@@ -84,4 +96,12 @@ defmodule Mobilizon.Media do
       where: fragment("? @> ?", p.file, ~s|{"url": "#{url}"}|)
     )
   end
+
+  @spec pictures_for_user_query(integer() | String.t()) :: Ecto.Query.t()
+  defp pictures_for_user_query(user_id) do
+    Picture
+    |> join(:inner, [p], a in Actor, on: p.actor_id == a.id)
+    |> join(:inner, [_p, a], u in User, on: a.user_id == u.id)
+    |> where([_p, _a, u], u.id == ^user_id)
+  end
 end

From b11d35cbec81251fdd9f798e5926a712bd2be388 Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Mon, 23 Nov 2020 12:31:15 +0100
Subject: [PATCH 7/8] Backend support to get used media size for users and
 actors

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 lib/graphql/resolvers/picture.ex         |  48 ++++
 lib/graphql/schema/actor.ex              |   2 +
 lib/graphql/schema/actors/application.ex |   6 +
 lib/graphql/schema/actors/group.ex       |   7 +-
 lib/graphql/schema/actors/person.ex      |   7 +-
 lib/graphql/schema/user.ex               |   7 +-
 lib/mobilizon/media/media.ex             |  43 +++
 test/graphql/resolvers/picture_test.exs  | 333 ++++++++++++++++++++++-
 8 files changed, 435 insertions(+), 18 deletions(-)

diff --git a/lib/graphql/resolvers/picture.ex b/lib/graphql/resolvers/picture.ex
index 9a17e22ef..5998070c3 100644
--- a/lib/graphql/resolvers/picture.ex
+++ b/lib/graphql/resolvers/picture.ex
@@ -98,4 +98,52 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
   end
 
   def remove_picture(_parent, _args, _resolution), do: {:error, :unauthenticated}
+
+  @doc """
+  Return the total media size for an actor
+  """
+  @spec actor_size(map(), map(), map()) ::
+          {:ok, integer()} | {:error, :unauthorized} | {:error, :unauthenticated}
+  def actor_size(%Actor{id: actor_id}, _args, %{
+        context: %{current_user: %User{} = user}
+      }) do
+    if can_get_actor_size?(user, actor_id) do
+      {:ok, Media.media_size_for_actor(actor_id)}
+    else
+      {:error, :unauthorized}
+    end
+  end
+
+  def actor_size(_parent, _args, _resolution), do: {:error, :unauthenticated}
+
+  @doc """
+  Return the total media size for a local user
+  """
+  @spec user_size(map(), map(), map()) ::
+          {:ok, integer()} | {:error, :unauthorized} | {:error, :unauthenticated}
+  def user_size(%User{id: user_id}, _args, %{
+        context: %{current_user: %User{} = logged_user}
+      }) do
+    if can_get_user_size?(logged_user, user_id) do
+      {:ok, Media.media_size_for_user(user_id)}
+    else
+      {:error, :unauthorized}
+    end
+  end
+
+  def user_size(_parent, _args, _resolution), do: {:error, :unauthenticated}
+
+  @spec can_get_user_size?(User.t(), integer()) :: boolean()
+  defp can_get_actor_size?(%User{role: role} = user, actor_id) do
+    role in [:moderator, :administrator] || owns_actor?(User.owns_actor(user, actor_id))
+  end
+
+  @spec owns_actor?({:is_owned, Actor.t() | nil}) :: boolean()
+  defp owns_actor?({:is_owned, %Actor{} = _actor}), do: true
+  defp owns_actor?({:is_owned, _}), do: false
+
+  @spec can_get_user_size?(User.t(), integer()) :: boolean()
+  defp can_get_user_size?(%User{role: role, id: logged_user_id}, user_id) do
+    user_id == logged_user_id || role in [:moderator, :administrator]
+  end
 end
diff --git a/lib/graphql/schema/actor.ex b/lib/graphql/schema/actor.ex
index 45617dca6..fae5c59e3 100644
--- a/lib/graphql/schema/actor.ex
+++ b/lib/graphql/schema/actor.ex
@@ -37,6 +37,8 @@ defmodule Mobilizon.GraphQL.Schema.ActorInterface do
     field(:followersCount, :integer, description: "Number of followers for this actor")
     field(:followingCount, :integer, description: "Number of actors following this actor")
 
+    field(:media_size, :integer, description: "The total size of the media from this actor")
+
     resolve_type(fn
       %Actor{type: :Person}, _ ->
         :person
diff --git a/lib/graphql/schema/actors/application.ex b/lib/graphql/schema/actors/application.ex
index 339991982..1371a1695 100644
--- a/lib/graphql/schema/actors/application.ex
+++ b/lib/graphql/schema/actors/application.ex
@@ -3,6 +3,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
   Schema representation for Group.
   """
 
+  alias Mobilizon.GraphQL.Resolvers.Picture
   use Absinthe.Schema.Notation
 
   @desc """
@@ -34,5 +35,10 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
     field(:followers, list_of(:follower), description: "List of followers")
     field(:followersCount, :integer, description: "Number of followers for this actor")
     field(:followingCount, :integer, description: "Number of actors following this actor")
+
+    field(:media_size, :integer,
+      resolve: &Picture.actor_size/3,
+      description: "The total size of the media from this actor"
+    )
   end
 end
diff --git a/lib/graphql/schema/actors/group.ex b/lib/graphql/schema/actors/group.ex
index abdcdc5ef..73f40792a 100644
--- a/lib/graphql/schema/actors/group.ex
+++ b/lib/graphql/schema/actors/group.ex
@@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
   import Absinthe.Resolution.Helpers, only: [dataloader: 1]
 
   alias Mobilizon.Addresses
-  alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Member, Post, Resource, Todos}
+  alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Member, Picture, Post, Resource, Todos}
   alias Mobilizon.GraphQL.Schema
 
   import_types(Schema.Actors.MemberType)
@@ -52,6 +52,11 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
     field(:followersCount, :integer, description: "Number of followers for this actor")
     field(:followingCount, :integer, description: "Number of actors following this actor")
 
+    field(:media_size, :integer,
+      resolve: &Picture.actor_size/3,
+      description: "The total size of the media from this actor"
+    )
+
     # This one should have a privacy setting
     field :organized_events, :paginated_event_list do
       arg(:after_datetime, :datetime,
diff --git a/lib/graphql/schema/actors/person.ex b/lib/graphql/schema/actors/person.ex
index 5970743be..1b8ba6e52 100644
--- a/lib/graphql/schema/actors/person.ex
+++ b/lib/graphql/schema/actors/person.ex
@@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
   import Absinthe.Resolution.Helpers, only: [dataloader: 1]
 
   alias Mobilizon.Events
-  alias Mobilizon.GraphQL.Resolvers.Person
+  alias Mobilizon.GraphQL.Resolvers.{Person, Picture}
   alias Mobilizon.GraphQL.Schema
 
   import_types(Schema.Events.FeedTokenType)
@@ -49,6 +49,11 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
     field(:followersCount, :integer, description: "Number of followers for this actor")
     field(:followingCount, :integer, description: "Number of actors following this actor")
 
+    field(:media_size, :integer,
+      resolve: &Picture.actor_size/3,
+      description: "The total size of the media from this actor"
+    )
+
     field(:feed_tokens, list_of(:feed_token),
       resolve: dataloader(Events),
       description: "A list of the feed tokens for this person"
diff --git a/lib/graphql/schema/user.ex b/lib/graphql/schema/user.ex
index 668a9c1a6..a4c34d55e 100644
--- a/lib/graphql/schema/user.ex
+++ b/lib/graphql/schema/user.ex
@@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
   import Absinthe.Resolution.Helpers, only: [dataloader: 1]
 
   alias Mobilizon.Events
-  alias Mobilizon.GraphQL.Resolvers.User
+  alias Mobilizon.GraphQL.Resolvers.{Picture, User}
   alias Mobilizon.GraphQL.Schema
 
   import_types(Schema.SortType)
@@ -120,6 +120,11 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
       arg(:limit, :integer, default_value: 10, description: "The limit of user media per page")
       resolve(&User.user_medias/3)
     end
+
+    field(:media_size, :integer,
+      resolve: &Picture.user_size/3,
+      description: "The total size of all the media from this user (from all their actors)"
+    )
   end
 
   @desc "The list of roles an user can have"
diff --git a/lib/mobilizon/media/media.ex b/lib/mobilizon/media/media.ex
index 3af6665f1..3946ce859 100644
--- a/lib/mobilizon/media/media.ex
+++ b/lib/mobilizon/media/media.ex
@@ -37,6 +37,16 @@ defmodule Mobilizon.Media do
     |> Repo.one()
   end
 
+  @doc """
+  List the paginated picture for an actor
+  """
+  @spec pictures_for_actor(integer | String.t(), integer | nil, integer | nil) :: Page.t()
+  def pictures_for_actor(actor_id, page, limit) do
+    actor_id
+    |> pictures_for_actor_query()
+    |> Page.build_page(page, limit)
+  end
+
   @doc """
   List the paginated picture for user
   """
@@ -47,6 +57,32 @@ defmodule Mobilizon.Media do
     |> Page.build_page(page, limit)
   end
 
+  @doc """
+  Calculate the sum of media size used by the user
+  """
+  @spec media_size_for_actor(integer | String.t()) :: integer()
+  def media_size_for_actor(actor_id) do
+    actor_id
+    |> pictures_for_actor_query()
+    |> select([:file])
+    |> Repo.all()
+    |> Enum.map(& &1.file.size)
+    |> Enum.sum()
+  end
+
+  @doc """
+  Calculate the sum of media size used by the user
+  """
+  @spec media_size_for_user(integer | String.t()) :: integer()
+  def media_size_for_user(user_id) do
+    user_id
+    |> pictures_for_user_query()
+    |> select([:file])
+    |> Repo.all()
+    |> Enum.map(& &1.file.size)
+    |> Enum.sum()
+  end
+
   @doc """
   Creates a picture.
   """
@@ -97,6 +133,13 @@ defmodule Mobilizon.Media do
     )
   end
 
+  @spec pictures_for_actor_query(integer() | String.t()) :: Ecto.Query.t()
+  defp pictures_for_actor_query(actor_id) do
+    Picture
+    |> join(:inner, [p], a in Actor, on: p.actor_id == a.id)
+    |> where([_p, a], a.id == ^actor_id)
+  end
+
   @spec pictures_for_user_query(integer() | String.t()) :: Ecto.Query.t()
   defp pictures_for_user_query(user_id) do
     Picture
diff --git a/test/graphql/resolvers/picture_test.exs b/test/graphql/resolvers/picture_test.exs
index 620335a45..4d0d8810c 100644
--- a/test/graphql/resolvers/picture_test.exs
+++ b/test/graphql/resolvers/picture_test.exs
@@ -10,6 +10,9 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
 
   alias Mobilizon.Web.Endpoint
 
+  @default_picture_details %{name: "my pic", alt: "represents something", file: "picture.png"}
+  @default_picture_path "test/fixtures/picture.png"
+
   setup %{conn: conn} do
     user = insert(:user)
     actor = insert(:actor, user: user)
@@ -30,6 +33,21 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
   }
   """
 
+  @upload_picture_mutation """
+  mutation UploadPicture($name: String!, $alt: String, $file: Upload!) {
+    uploadPicture(
+      name: $name
+      alt: $alt
+      file: $file
+    ) {
+        url
+        name
+        content_type
+        size
+    }
+  }
+  """
+
   describe "Resolver: Get picture" do
     test "picture/3 returns the information on a picture", %{conn: conn} do
       %Picture{id: id} = picture = insert(:picture)
@@ -59,21 +77,6 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
   end
 
   describe "Resolver: Upload picture" do
-    @upload_picture_mutation """
-    mutation UploadPicture($name: String!, $alt: String, $file: Upload!) {
-      uploadPicture(
-        name: $name
-        alt: $alt
-        file: $file
-      ) {
-          url
-          name
-          content_type
-          size
-      }
-    }
-    """
-
     test "upload_picture/3 uploads a new picture", %{conn: conn, user: user} do
       picture = %{name: "my pic", alt: "represents something", file: "picture.png"}
 
@@ -185,4 +188,304 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
       assert hd(res["errors"])["status_code"] == 401
     end
   end
+
+  describe "Resolver: Get actor media size" do
+    @actor_media_size_query """
+    query LoggedPerson {
+      loggedPerson {
+        id
+        mediaSize
+      }
+    }
+    """
+
+    test "with own actor", %{conn: conn} do
+      user = insert(:user)
+      insert(:actor, user: user)
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(query: @actor_media_size_query)
+
+      assert res["data"]["loggedPerson"]["mediaSize"] == 0
+
+      res = upload_picture(conn, user)
+      assert res["data"]["uploadPicture"]["size"] == 10_097
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(query: @actor_media_size_query)
+
+      assert res["data"]["loggedPerson"]["mediaSize"] == 10_097
+
+      res =
+        upload_picture(
+          conn,
+          user,
+          "test/fixtures/image.jpg",
+          Map.put(@default_picture_details, :file, "image.jpg")
+        )
+
+      assert res["data"]["uploadPicture"]["size"] == 13_227
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(query: @actor_media_size_query)
+
+      assert res["data"]["loggedPerson"]["mediaSize"] == 23_324
+    end
+
+    @list_actors_query """
+    query ListPersons($preferredUsername: String) {
+      persons(preferredUsername: $preferredUsername) {
+        total,
+        elements {
+          id
+          mediaSize
+        }
+      }
+    }
+    """
+
+    test "as a moderator", %{conn: conn} do
+      moderator = insert(:user, role: :moderator)
+      user = insert(:user)
+      actor = insert(:actor, user: user)
+
+      res =
+        conn
+        |> auth_conn(moderator)
+        |> AbsintheHelpers.graphql_query(
+          query: @list_actors_query,
+          variables: %{preferredUsername: actor.preferred_username}
+        )
+
+      assert is_nil(res["errors"])
+      assert hd(res["data"]["persons"]["elements"])["mediaSize"] == 0
+
+      upload_picture(conn, user)
+
+      res =
+        conn
+        |> auth_conn(moderator)
+        |> AbsintheHelpers.graphql_query(
+          query: @list_actors_query,
+          variables: %{preferredUsername: actor.preferred_username}
+        )
+
+      assert is_nil(res["errors"])
+      assert hd(res["data"]["persons"]["elements"])["mediaSize"] == 10_097
+    end
+
+    @event_organizer_media_query """
+    query Event($uuid: UUID!) {
+      event(uuid: $uuid) {
+        id
+        organizerActor {
+          id
+          mediaSize
+        }
+      }
+    }
+    """
+
+    test "as a different user", %{conn: conn} do
+      user = insert(:user)
+      event = insert(:event)
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(
+          query: @event_organizer_media_query,
+          variables: %{uuid: event.uuid}
+        )
+
+      assert hd(res["errors"])["message"] == "unauthorized"
+    end
+
+    test "without being logged-in", %{conn: conn} do
+      event = insert(:event)
+
+      res =
+        conn
+        |> AbsintheHelpers.graphql_query(
+          query: @event_organizer_media_query,
+          variables: %{uuid: event.uuid}
+        )
+
+      assert hd(res["errors"])["message"] == "unauthenticated"
+    end
+  end
+
+  describe "Resolver: Get user media size" do
+    @user_media_size_query """
+    query LoggedUser {
+      loggedUser {
+        id
+        mediaSize
+      }
+    }
+    """
+
+    @change_default_actor_mutation """
+    mutation ChangeDefaultActor($preferredUsername: String!) {
+      changeDefaultActor(preferredUsername: $preferredUsername) {
+          defaultActor {
+            id
+            preferredUsername
+          }
+        }
+      }
+    """
+
+    test "with own user", %{conn: conn} do
+      user = insert(:user)
+      insert(:actor, user: user)
+      actor_2 = insert(:actor, user: user)
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(query: @user_media_size_query)
+
+      assert res["errors"] == nil
+      assert res["data"]["loggedUser"]["mediaSize"] == 0
+
+      res = upload_picture(conn, user)
+      assert res["data"]["uploadPicture"]["size"] == 10_097
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(query: @user_media_size_query)
+
+      assert res["data"]["loggedUser"]["mediaSize"] == 10_097
+
+      res =
+        upload_picture(
+          conn,
+          user,
+          "test/fixtures/image.jpg",
+          Map.put(@default_picture_details, :file, "image.jpg")
+        )
+
+      assert res["data"]["uploadPicture"]["size"] == 13_227
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(query: @user_media_size_query)
+
+      assert res["data"]["loggedUser"]["mediaSize"] == 23_324
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(
+          query: @change_default_actor_mutation,
+          variables: %{preferredUsername: actor_2.preferred_username}
+        )
+
+      assert is_nil(res["errors"])
+
+      res =
+        upload_picture(
+          conn,
+          user,
+          "test/fixtures/image.jpg",
+          Map.put(@default_picture_details, :file, "image.jpg")
+        )
+
+      assert res["data"]["uploadPicture"]["size"] == 13_227
+
+      res =
+        conn
+        |> auth_conn(user)
+        |> AbsintheHelpers.graphql_query(query: @user_media_size_query)
+
+      assert res["data"]["loggedUser"]["mediaSize"] == 36_551
+    end
+
+    @list_users_query """
+    query ListUsers($email: String) {
+      users(email: $email) {
+        total,
+        elements {
+          id
+          mediaSize
+        }
+      }
+    }
+    """
+
+    test "as a moderator", %{conn: conn} do
+      moderator = insert(:user, role: :moderator)
+      user = insert(:user)
+      insert(:actor, user: user)
+
+      res =
+        conn
+        |> auth_conn(moderator)
+        |> AbsintheHelpers.graphql_query(
+          query: @list_users_query,
+          variables: %{email: user.email}
+        )
+
+      assert is_nil(res["errors"])
+      assert hd(res["data"]["users"]["elements"])["mediaSize"] == 0
+
+      res = upload_picture(conn, user)
+      assert is_nil(res["errors"])
+      assert res["data"]["uploadPicture"]["size"] == 10_097
+
+      res =
+        conn
+        |> auth_conn(moderator)
+        |> AbsintheHelpers.graphql_query(
+          query: @list_users_query,
+          variables: %{email: user.email}
+        )
+
+      assert is_nil(res["errors"])
+      assert hd(res["data"]["users"]["elements"])["mediaSize"] == 10_097
+    end
+
+    test "without being logged-in", %{conn: conn} do
+      res =
+        conn
+        |> AbsintheHelpers.graphql_query(query: @user_media_size_query)
+
+      assert hd(res["errors"])["message"] == "You need to be logged-in to view current user"
+    end
+  end
+
+  @spec upload_picture(Plug.Conn.t(), Mobilizon.Users.User.t(), String.t(), map()) :: map()
+  defp upload_picture(
+         conn,
+         user,
+         picture_path \\ @default_picture_path,
+         picture_details \\ @default_picture_details
+       ) do
+    map = %{
+      "query" => @upload_picture_mutation,
+      "variables" => picture_details,
+      picture_details.file => %Plug.Upload{
+        path: picture_path,
+        filename: picture_details.file
+      }
+    }
+
+    conn
+    |> auth_conn(user)
+    |> put_req_header("content-type", "multipart/form-data")
+    |> post(
+      "/api",
+      map
+    )
+    |> json_response(200)
+  end
 end

From 2ef973000e1aed9e3ade674302904d8776952d7b Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Mon, 23 Nov 2020 16:58:50 +0100
Subject: [PATCH 8/8] Show user and actors media usage in admin

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/src/graphql/actor.ts                  |  2 ++
 js/src/graphql/group.ts                  |  1 +
 js/src/graphql/user.ts                   |  1 +
 js/src/i18n/en_US.json                   |  3 ++-
 js/src/i18n/fr_FR.json                   |  3 ++-
 js/src/types/actor/actor.model.ts        |  3 +++
 js/src/types/current-user.model.ts       |  1 +
 js/src/utils/datetime.ts                 | 15 ++++++++++++++-
 js/src/views/Admin/AdminGroupProfile.vue |  5 +++++
 js/src/views/Admin/AdminProfile.vue      | 18 +++++++++++-------
 js/src/views/Admin/AdminUserProfile.vue  | 11 ++++++++++-
 11 files changed, 52 insertions(+), 11 deletions(-)

diff --git a/js/src/graphql/actor.ts b/js/src/graphql/actor.ts
index 6c9f68e6c..958d16f9b 100644
--- a/js/src/graphql/actor.ts
+++ b/js/src/graphql/actor.ts
@@ -10,6 +10,7 @@ export const FETCH_PERSON = gql`
       summary
       preferredUsername
       suspended
+      mediaSize
       avatar {
         id
         name
@@ -51,6 +52,7 @@ export const GET_PERSON = gql`
       summary
       preferredUsername
       suspended
+      mediaSize
       avatar {
         id
         name
diff --git a/js/src/graphql/group.ts b/js/src/graphql/group.ts
index 4815d5e4b..4c946b89d 100644
--- a/js/src/graphql/group.ts
+++ b/js/src/graphql/group.ts
@@ -84,6 +84,7 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
       id
       url
     }
+    mediaSize
     organizedEvents(
       afterDatetime: $afterDateTime
       beforeDatetime: $beforeDateTime
diff --git a/js/src/graphql/user.ts b/js/src/graphql/user.ts
index c6fc40776..4f85f388a 100644
--- a/js/src/graphql/user.ts
+++ b/js/src/graphql/user.ts
@@ -200,6 +200,7 @@ export const GET_USER = gql`
       currentSignInAt
       locale
       disabled
+      mediaSize
       defaultActor {
         id
       }
diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json
index 918c1a7d6..d03b83648 100644
--- a/js/src/i18n/en_US.json
+++ b/js/src/i18n/en_US.json
@@ -799,5 +799,6 @@
   "Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.": "Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.",
   "Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere.": "Mobilizon is a federated software, meaning you can interact - depending on your admin federation settings - with content from other instances, such as joining groups or events that were created elsewhere.",
   "This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.": "This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.",
-  "If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:": "If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:"
+  "If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:": "If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:",
+  "Uploaded media size": "Uploaded media size"
 }
diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json
index 34663c42c..1e52c9b99 100644
--- a/js/src/i18n/fr_FR.json
+++ b/js/src/i18n/fr_FR.json
@@ -887,5 +887,6 @@
   "Mobilizon uses a system of profiles to compartiment your activities. You will be able to create as many profiles as you want.": "Mobilizon utilise un système de profils pour compartimenter vos activités. Vous pourrez créer autant de profils que vous voulez.",
   "Mobilizon is a federated software, meaning you can interact - depending on your admin's federation settings - with content from other instances, such as joining groups or events that were created elsewhere.": "Mobilizon est un logiciel fédéré, ce qui signifie que vous pouvez interagir - en fonction des paramètres de fédération de votre administrateur·ice - avec du contenu d'autres instances, comme par exemple rejoindre des groupes ou des événements ayant été créés ailleurs.",
   "This instance, <b>{instanceName} ({domain})</b>, hosts your profile, so remember its name.": "Cette instance, <b>{instanceName} ({domain})</b>, héberge votre profil, donc notez bien son nom.",
-  "If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:": "Si l'on vous demande votre identité fédérée, elle est composée de votre nom d'utilisateur·ice et de votre instance. Par exemple, l'identité fédérée de votre premier profil est :"
+  "If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:": "Si l'on vous demande votre identité fédérée, elle est composée de votre nom d'utilisateur·ice et de votre instance. Par exemple, l'identité fédérée de votre premier profil est :",
+  "Uploaded media size": "Taille des médias téléversés"
 }
diff --git a/js/src/types/actor/actor.model.ts b/js/src/types/actor/actor.model.ts
index 65c838f22..7b11331f7 100644
--- a/js/src/types/actor/actor.model.ts
+++ b/js/src/types/actor/actor.model.ts
@@ -13,6 +13,7 @@ export interface IActor {
   url: string;
   name: string;
   domain: string | null;
+  mediaSize: number;
   summary: string;
   preferredUsername: string;
   suspended: boolean;
@@ -30,6 +31,8 @@ export class Actor implements IActor {
 
   domain: string | null = null;
 
+  mediaSize = 0;
+
   name = "";
 
   preferredUsername = "";
diff --git a/js/src/types/current-user.model.ts b/js/src/types/current-user.model.ts
index eed7d9eb9..3b2a29ea0 100644
--- a/js/src/types/current-user.model.ts
+++ b/js/src/types/current-user.model.ts
@@ -39,6 +39,7 @@ export interface IUser extends ICurrentUser {
   actors: IPerson[];
   disabled: boolean;
   participations: Paginate<IParticipant>;
+  mediaSize: number;
   drafts: IEvent[];
   settings: IUserSettings;
   locale: string;
diff --git a/js/src/utils/datetime.ts b/js/src/utils/datetime.ts
index f55420d5f..e0b3210a3 100644
--- a/js/src/utils/datetime.ts
+++ b/js/src/utils/datetime.ts
@@ -18,4 +18,17 @@ function localeShortWeekDayNames(): string[] {
   return weekDayNames;
 }
 
-export { localeMonthNames, localeShortWeekDayNames };
+// https://stackoverflow.com/a/18650828/10204399
+function formatBytes(bytes: number, decimals = 2): string {
+  if (bytes === 0) return "0 Bytes";
+
+  const k = 1024;
+  const dm = decimals < 0 ? 0 : decimals;
+  const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
+
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+  return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
+}
+
+export { localeMonthNames, localeShortWeekDayNames, formatBytes };
diff --git a/js/src/views/Admin/AdminGroupProfile.vue b/js/src/views/Admin/AdminGroupProfile.vue
index 8a27d06f7..6b7cb9279 100644
--- a/js/src/views/Admin/AdminGroupProfile.vue
+++ b/js/src/views/Admin/AdminGroupProfile.vue
@@ -198,6 +198,7 @@
 <script lang="ts">
 import { Component, Vue, Prop } from "vue-property-decorator";
 import { GET_GROUP, REFRESH_PROFILE } from "@/graphql/group";
+import { formatBytes } from "@/utils/datetime";
 import { SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor";
 import { IGroup, MemberRole } from "../../types/actor";
 import { usernameWithDomain, IActor } from "../../types/actor/actor.model";
@@ -258,6 +259,10 @@ export default class AdminGroupProfile extends Vue {
         key: this.$t("Domain") as string,
         value: (this.group.domain ? this.group.domain : this.$t("Local")) as string,
       },
+      {
+        key: this.$i18n.t("Uploaded media size") as string,
+        value: formatBytes(this.group.mediaSize),
+      },
     ];
     return res;
   }
diff --git a/js/src/views/Admin/AdminProfile.vue b/js/src/views/Admin/AdminProfile.vue
index 4f04b25fe..8248ac97e 100644
--- a/js/src/views/Admin/AdminProfile.vue
+++ b/js/src/views/Admin/AdminProfile.vue
@@ -126,11 +126,11 @@
 </template>
 <script lang="ts">
 import { Component, Vue, Prop } from "vue-property-decorator";
+import { formatBytes } from "@/utils/datetime";
 import { GET_PERSON, SUSPEND_PROFILE, UNSUSPEND_PROFILE } from "../../graphql/actor";
 import { IPerson } from "../../types/actor";
 import { usernameWithDomain } from "../../types/actor/actor.model";
 import RouteName from "../../router/name";
-import { IEvent } from "../../types/event.model";
 import ActorCard from "../../components/Account/ActorCard.vue";
 
 const EVENTS_PER_PAGE = 10;
@@ -171,9 +171,9 @@ export default class AdminProfile extends Vue {
 
   participationsPage = 1;
 
-  get metadata(): Array<object> {
+  get metadata(): Array<Record<string, unknown>> {
     if (!this.person) return [];
-    const res: object[] = [
+    const res: Record<string, unknown>[] = [
       {
         key: this.$t("Status") as string,
         value: this.person.suspended ? this.$t("Suspended") : this.$t("Active"),
@@ -182,6 +182,10 @@ export default class AdminProfile extends Vue {
         key: this.$t("Domain") as string,
         value: this.person.domain ? this.person.domain : this.$t("Local"),
       },
+      {
+        key: this.$i18n.t("Uploaded media size"),
+        value: formatBytes(this.person.mediaSize),
+      },
     ];
     if (!this.person.domain && this.person.user) {
       res.push({
@@ -193,7 +197,7 @@ export default class AdminProfile extends Vue {
     return res;
   }
 
-  async suspendProfile() {
+  async suspendProfile(): Promise<void> {
     this.$apollo.mutate<{ suspendProfile: { id: string } }>({
       mutation: SUSPEND_PROFILE,
       variables: {
@@ -229,7 +233,7 @@ export default class AdminProfile extends Vue {
     });
   }
 
-  async unsuspendProfile() {
+  async unsuspendProfile(): Promise<void> {
     const profileID = this.id;
     this.$apollo.mutate<{ unsuspendProfile: { id: string } }>({
       mutation: UNSUSPEND_PROFILE,
@@ -249,7 +253,7 @@ export default class AdminProfile extends Vue {
     });
   }
 
-  async onOrganizedEventsPageChange(page: number) {
+  async onOrganizedEventsPageChange(page: number): Promise<void> {
     this.organizedEventsPage = page;
     await this.$apollo.queries.person.fetchMore({
       variables: {
@@ -274,7 +278,7 @@ export default class AdminProfile extends Vue {
     });
   }
 
-  async onParticipationsPageChange(page: number) {
+  async onParticipationsPageChange(page: number): Promise<void> {
     this.participationsPage = page;
     await this.$apollo.queries.person.fetchMore({
       variables: {
diff --git a/js/src/views/Admin/AdminUserProfile.vue b/js/src/views/Admin/AdminUserProfile.vue
index aa1b9c881..fa0c43084 100644
--- a/js/src/views/Admin/AdminUserProfile.vue
+++ b/js/src/views/Admin/AdminUserProfile.vue
@@ -26,7 +26,7 @@
     </nav>
     <table v-if="metadata.length > 0" class="table is-fullwidth">
       <tbody>
-        <tr v-for="{ key, value, link, elements } in metadata" :key="key">
+        <tr v-for="{ key, value, link, elements, type } in metadata" :key="key">
           <td>{{ key }}</td>
           <td v-if="elements && elements.length > 0">
             <ul v-for="{ value, link: elementLink, active } in elements" :key="value">
@@ -46,6 +46,9 @@
               {{ value }}
             </router-link>
           </td>
+          <td v-else-if="type == 'code'">
+            <code>{{ value }}</code>
+          </td>
           <td v-else>{{ value }}</td>
         </tr>
       </tbody>
@@ -60,6 +63,7 @@
 <script lang="ts">
 import { Component, Vue, Prop } from "vue-property-decorator";
 import { Route } from "vue-router";
+import { formatBytes } from "@/utils/datetime";
 import { GET_USER, SUSPEND_USER } from "../../graphql/user";
 import { usernameWithDomain } from "../../types/actor/actor.model";
 import RouteName from "../../router/name";
@@ -139,11 +143,16 @@ export default class AdminUserProfile extends Vue {
       {
         key: this.$i18n.t("Last IP adress"),
         value: this.user.currentSignInIp || this.$t("Unknown"),
+        type: "code",
       },
       {
         key: this.$i18n.t("Participations"),
         value: this.user.participations.total,
       },
+      {
+        key: this.$i18n.t("Uploaded media size"),
+        value: formatBytes(this.user.mediaSize),
+      },
     ];
   }