Merge branch 'allow-to-remove-pictures' into 'master'

Allow to remove pictures and show user media size usage

Closes #281

See merge request framasoft/mobilizon!721
This commit is contained in:
Thomas Citharel 2020-11-23 17:19:22 +01:00
commit a368c9542b
33 changed files with 786 additions and 137 deletions

View File

@ -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)$}
]
]

View File

@ -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>

View File

@ -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

View File

@ -84,6 +84,7 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
id
url
}
mediaSize
organizedEvents(
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime

View File

@ -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
}
}
`;

View File

@ -200,6 +200,7 @@ export const GET_USER = gql`
currentSignInAt
locale
disabled
mediaSize
defaultActor {
id
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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 = "";

View File

@ -39,6 +39,7 @@ export interface IUser extends ICurrentUser {
actors: IPerson[];
disabled: boolean;
participations: Paginate<IParticipant>;
mediaSize: number;
drafts: IEvent[];
settings: IUserSettings;
locale: string;

View File

@ -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,

View File

@ -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 };

View File

@ -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 {

View File

@ -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", {

View File

@ -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;
}

View File

@ -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: {

View File

@ -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),
},
];
}

View File

@ -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);

View File

@ -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>

View File

@ -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

View File

@ -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,74 @@ 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}
@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

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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"

View File

@ -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
@ -35,7 +43,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 +56,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

View File

@ -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

View File

@ -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)
@ -110,6 +110,21 @@ 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
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"

View File

@ -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,52 @@ 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
"""
@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 """
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.
"""
@ -84,4 +132,19 @@ defmodule Mobilizon.Media do
where: fragment("? @> ?", p.file, ~s|{"url": "#{url}"}|)
)
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
|> 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

View File

@ -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

View File

@ -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

View File

@ -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)
@ -17,53 +20,59 @@ 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
}
}
"""
@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", 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
@ -71,22 +80,9 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
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 +97,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 +124,368 @@ 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
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