From 1cd680526a9073b9780d8bf2f963f2883df33a7e Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Fri, 20 Nov 2020 16:35:48 +0100 Subject: [PATCH 1/8] Add backend to remove pictures Signed-off-by: Thomas Citharel --- 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 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 --- .../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 Date: Fri, 20 Nov 2020 18:34:13 +0100 Subject: [PATCH 3/8] Refactor Picture upload Signed-off-by: Thomas Citharel --- 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 @@ @@ -45,16 +52,22 @@ figure.image { color: #eee; } } + +.action-buttons { + display: flex; + flex-direction: column; +} 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(file: File | null, name: string, alt?: string): Record { +export function buildFileVariable(file: File | null, name: string, alt?: string): Record { 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 @@ {{ $t("I create an identity") }} - + 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 { 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 @@
{{ $t("General information") }} - + @@ -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 @@ -
+
@@ -43,7 +43,7 @@ @@ -51,7 +51,7 @@

{{ $t("Group visibility") }}

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 @@ Date: Fri, 20 Nov 2020 19:01:42 +0100 Subject: [PATCH 4/8] Fix some bad french translations Signed-off-by: Thomas Citharel --- 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 Date: Fri, 20 Nov 2020 19:27:40 +0100 Subject: [PATCH 5/8] Update some outdated dev config Signed-off-by: Thomas Citharel --- 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 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 --- 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 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 --- 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 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 --- 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, {instanceName} ({domain}), hosts your profile, so remember its name.": "This instance, {instanceName} ({domain}), 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, {instanceName} ({domain}), hosts your profile, so remember its name.": "Cette instance, {instanceName} ({domain}), 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; + 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 @@