Track usage of media files and add a job to clean them

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2020-11-26 11:41:13 +01:00
parent c19e326bd8
commit c9457fe0d3
78 changed files with 1405 additions and 700 deletions

View File

@@ -10,7 +10,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Media.Picture
alias Mobilizon.Medias.Media
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Federator, Relay}
@@ -333,50 +333,50 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
Return AS Link data from
* a `Plug.Upload` struct, stored an returned
* a `Picture`, directly returned
* a map containing picture information, stored, saved and returned
* a `Media`, directly returned
* a map containing media information, stored, saved and returned
Save picture data from %Plug.Upload{} and return AS Link data.
Save media data from %Plug.Upload{} and return AS Link data.
"""
def make_picture_data(%Plug.Upload{} = picture, opts) do
case Mobilizon.Web.Upload.store(picture, opts) do
{:ok, picture} ->
picture
def make_media_data(%Plug.Upload{} = media, opts) do
case Mobilizon.Web.Upload.store(media, opts) do
{:ok, media} ->
media
_ ->
nil
end
end
def make_picture_data(%Picture{} = picture) do
Converter.Picture.model_to_as(picture)
def make_media_data(%Media{} = media) do
Converter.Media.model_to_as(media)
end
def make_picture_data(picture) when is_map(picture) do
def make_media_data(media) when is_map(media) do
with {:ok, %{"url" => [%{"href" => url, "mediaType" => content_type}], "size" => size}} <-
Mobilizon.Web.Upload.store(picture.file),
{:picture_exists, nil} <- {:picture_exists, Mobilizon.Media.get_picture_by_url(url)},
{:ok, %Picture{file: _file} = picture} <-
Mobilizon.Media.create_picture(%{
Mobilizon.Web.Upload.store(media.file),
{:media_exists, nil} <- {:media_exists, Mobilizon.Medias.get_media_by_url(url)},
{:ok, %Media{file: _file} = media} <-
Mobilizon.Medias.create_media(%{
"file" => %{
"url" => url,
"name" => picture.name,
"name" => media.name,
"content_type" => content_type,
"size" => size
},
"actor_id" => picture.actor_id
"actor_id" => media.actor_id
}) do
Converter.Picture.model_to_as(picture)
Converter.Media.model_to_as(media)
else
{:picture_exists, %Picture{file: _file} = picture} ->
Converter.Picture.model_to_as(picture)
{:media_exists, %Media{file: _file} = media} ->
Converter.Media.model_to_as(media)
err ->
err
end
end
def make_picture_data(nil), do: nil
def make_media_data(nil), do: nil
@doc """
Make announce activity data for the given actor and object

View File

@@ -10,11 +10,11 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
alias Mobilizon.Addresses
alias Mobilizon.Addresses.Address
alias Mobilizon.Events.Event, as: EventModel
alias Mobilizon.Media.Picture
alias Mobilizon.Medias.Media
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Address, as: AddressConverter
alias Mobilizon.Federation.ActivityStream.Converter.Picture, as: PictureConverter
alias Mobilizon.Federation.ActivityStream.Converter.Media, as: MediaConverter
import Mobilizon.Federation.ActivityStream.Converter.Utils,
only: [
@@ -55,10 +55,10 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
picture_id =
with true <- length(attachments) > 0,
{:ok, %Picture{id: picture_id}} <-
{:ok, %Media{id: picture_id}} <-
attachments
|> hd()
|> PictureConverter.find_or_create_picture(actor_id) do
|> MediaConverter.find_or_create_media(actor_id) do
picture_id
else
_err ->
@@ -239,7 +239,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
res,
"attachment",
[],
&(&1 ++ [PictureConverter.model_to_as(event.picture)])
&(&1 ++ [MediaConverter.model_to_as(event.picture)])
)
end

View File

@@ -0,0 +1,63 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Media do
@moduledoc """
Media converter.
This module allows to convert events from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Medias
alias Mobilizon.Medias.Media, as: MediaModel
alias Mobilizon.Web.Upload
@http_options [
ssl: [{:versions, [:"tlsv1.2"]}]
]
@doc """
Convert a media struct to an ActivityStream representation.
"""
@spec model_to_as(MediaModel.t()) :: map
def model_to_as(%MediaModel{file: file}) do
%{
"type" => "Document",
"mediaType" => file.content_type,
"url" => file.url,
"name" => file.name
}
end
@doc """
Save media data from raw data and return AS Link data.
"""
def find_or_create_media(%{"type" => "Link", "href" => url}, actor_id),
do: find_or_create_media(url, actor_id)
def find_or_create_media(
%{"type" => "Document", "url" => media_url, "name" => name},
actor_id
)
when is_bitstring(media_url) do
with {:ok, %{body: body}} <- Tesla.get(media_url, opts: @http_options),
{:ok, %{name: name, url: url, content_type: content_type, size: size}} <-
Upload.store(%{body: body, name: name}),
{:media_exists, nil} <- {:media_exists, Medias.get_media_by_url(url)} do
Medias.create_media(%{
"file" => %{
"url" => url,
"name" => name,
"content_type" => content_type,
"size" => size
},
"actor_id" => actor_id
})
else
{:media_exists, %MediaModel{file: _file} = media} ->
{:ok, media}
err ->
err
end
end
end

View File

@@ -1,63 +0,0 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Picture do
@moduledoc """
Picture converter.
This module allows to convert events from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Media
alias Mobilizon.Media.Picture, as: PictureModel
alias Mobilizon.Web.Upload
@http_options [
ssl: [{:versions, [:"tlsv1.2"]}]
]
@doc """
Convert a picture struct to an ActivityStream representation.
"""
@spec model_to_as(PictureModel.t()) :: map
def model_to_as(%PictureModel{file: file}) do
%{
"type" => "Document",
"mediaType" => file.content_type,
"url" => file.url,
"name" => file.name
}
end
@doc """
Save picture data from raw data and return AS Link data.
"""
def find_or_create_picture(%{"type" => "Link", "href" => url}, actor_id),
do: find_or_create_picture(url, actor_id)
def find_or_create_picture(
%{"type" => "Document", "url" => picture_url, "name" => name},
actor_id
)
when is_bitstring(picture_url) do
with {:ok, %{body: body}} <- Tesla.get(picture_url, opts: @http_options),
{:ok, %{name: name, url: url, content_type: content_type, size: size}} <-
Upload.store(%{body: body, name: name}),
{:picture_exists, nil} <- {:picture_exists, Media.get_picture_by_url(url)} do
Media.create_picture(%{
"file" => %{
"url" => url,
"name" => name,
"content_type" => content_type,
"size" => size
},
"actor_id" => actor_id
})
else
{:picture_exists, %PictureModel{file: _file} = picture} ->
{:ok, picture}
err ->
err
end
end
end

View File

@@ -1,34 +1,59 @@
defmodule Mobilizon.GraphQL.API.Comments do
@moduledoc """
API for Comments.
API for discussions and comments.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Activity
alias Mobilizon.GraphQL.API.Utils
@doc """
Create a comment
Creates a comment from an actor
"""
@spec create_comment(map) :: {:ok, Activity.t(), Comment.t()} | any
def create_comment(args) do
args = extract_pictures_from_comment_body(args)
ActivityPub.create(:comment, args, true)
end
@doc """
Updates a comment
"""
@spec update_comment(Comment.t(), map()) :: {:ok, Activity.t(), Comment.t()} | any
def update_comment(%Comment{} = comment, args) do
args = extract_pictures_from_comment_body(args)
ActivityPub.update(comment, args, true)
end
@doc """
Deletes a comment
Deletes a comment from an actor
"""
@spec delete_comment(Comment.t(), Actor.t()) :: {:ok, Activity.t(), Comment.t()} | any
def delete_comment(%Comment{} = comment, %Actor{} = actor) do
ActivityPub.delete(comment, actor, true)
end
@doc """
Creates a discussion (or reply to a discussion)
"""
@spec create_discussion(map()) :: map()
def create_discussion(args) do
args = extract_pictures_from_comment_body(args)
ActivityPub.create(
:discussion,
args,
true
)
end
@spec extract_pictures_from_comment_body(map()) :: map()
defp extract_pictures_from_comment_body(%{text: text, actor_id: actor_id} = args) do
pictures = Utils.extract_pictures_from_body(text, actor_id)
Map.put(args, :media, pictures)
end
defp extract_pictures_from_comment_body(args), do: args
end

View File

@@ -8,6 +8,7 @@ defmodule Mobilizon.GraphQL.API.Events do
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Utils}
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
@doc """
Create an event
@@ -15,6 +16,7 @@ defmodule Mobilizon.GraphQL.API.Events do
@spec create_event(map) :: {:ok, Activity.t(), Event.t()} | any
def create_event(args) do
with organizer_actor <- Map.get(args, :organizer_actor),
args <- extract_pictures_from_event_body(args, organizer_actor),
args <-
Map.update(args, :picture, nil, fn picture ->
process_picture(picture, organizer_actor)
@@ -30,6 +32,7 @@ defmodule Mobilizon.GraphQL.API.Events do
@spec update_event(map, Event.t()) :: {:ok, Activity.t(), Event.t()} | any
def update_event(args, %Event{} = event) do
with organizer_actor <- Map.get(args, :organizer_actor),
args <- extract_pictures_from_event_body(args, organizer_actor),
args <-
Map.update(args, :picture, nil, fn picture ->
process_picture(picture, organizer_actor)
@@ -40,23 +43,32 @@ defmodule Mobilizon.GraphQL.API.Events do
@doc """
Trigger the deletion of an event
If the event is deleted by
"""
def delete_event(%Event{} = event, %Actor{} = actor, federate \\ true) do
ActivityPub.delete(event, actor, federate)
end
defp process_picture(nil, _), do: nil
defp process_picture(%{picture_id: _picture_id} = args, _), do: args
defp process_picture(%{media_id: _picture_id} = args, _), do: args
defp process_picture(%{picture: picture}, %Actor{id: actor_id}) do
defp process_picture(%{media: media}, %Actor{id: actor_id}) do
%{
file:
picture
media
|> Map.get(:file)
|> Utils.make_picture_data(description: Map.get(picture, :name)),
|> Utils.make_media_data(description: Map.get(media, :name)),
actor_id: actor_id
}
end
@spec extract_pictures_from_event_body(map(), Actor.t()) :: map()
defp extract_pictures_from_event_body(
%{description: description} = args,
%Actor{id: organizer_actor_id}
) do
pictures = APIUtils.extract_pictures_from_body(description, organizer_actor_id)
Map.put(args, :media, pictures)
end
defp extract_pictures_from_event_body(args, _), do: args
end

View File

@@ -3,7 +3,8 @@ defmodule Mobilizon.GraphQL.API.Utils do
Utils for API.
"""
alias Mobilizon.Config
alias Mobilizon.{Config, Medias}
alias Mobilizon.Medias.Media
alias Mobilizon.Service.Formatter
@doc """
@@ -40,4 +41,41 @@ defmodule Mobilizon.GraphQL.API.Utils do
{:error, "Comment must be up to #{max_size} characters"}
end
end
@doc """
Use the data-media-id attributes to extract media from body text
"""
@spec extract_pictures_from_body(String.t(), integer() | String.t()) :: list(Media.t())
def extract_pictures_from_body(body, actor_id) do
body
|> do_extract_pictures_from_body()
|> Enum.map(&fetch_picture(&1, actor_id))
|> Enum.filter(& &1)
end
@spec do_extract_pictures_from_body(String.t()) :: list(String.t())
defp do_extract_pictures_from_body(body) when is_nil(body) or body == "", do: []
defp do_extract_pictures_from_body(body) do
{:ok, document} = Floki.parse_document(body)
document
|> Floki.attribute("img", "data-media-id")
end
@spec fetch_picture(String.t() | integer(), String.t() | integer()) :: Media.t() | nil
defp fetch_picture(id, actor_id) do
with %Media{actor_id: media_actor_id} = media <- Medias.get_media(id),
{:owns_media, true} <-
{:owns_media, check_actor_owns_media?(actor_id, media_actor_id)} do
media
else
_ -> nil
end
end
@spec check_actor_owns_media?(integer() | String.t(), integer() | String.t()) :: boolean()
defp check_actor_owns_media?(actor_id, media_actor_id) do
actor_id == media_actor_id || Mobilizon.Actors.is_member?(media_actor_id, actor_id)
end
end

View File

@@ -7,6 +7,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Discussion do
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.GraphQL.API.Comments
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
import Mobilizon.Web.Gettext
@@ -94,17 +95,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Discussion do
with {:actor, %Actor{id: creator_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(creator_id, group_id)},
{:ok, _activity, %Discussion{} = discussion} <-
ActivityPub.create(
:discussion,
%{
title: title,
text: text,
actor_id: group_id,
creator_id: creator_id,
attributed_to_id: group_id
},
true
) do
Comments.create_discussion(%{
title: title,
text: text,
actor_id: group_id,
creator_id: creator_id,
attributed_to_id: group_id
}) do
{:ok, discussion}
else
{:member, false} ->
@@ -134,19 +131,15 @@ defmodule Mobilizon.GraphQL.Resolvers.Discussion do
{:no_discussion, Discussions.get_discussion(discussion_id)},
{:member, true} <- {:member, Actors.is_member?(creator_id, actor_id)},
{:ok, _activity, %Discussion{} = discussion} <-
ActivityPub.create(
:discussion,
%{
text: text,
discussion_id: discussion_id,
actor_id: creator_id,
attributed_to_id: actor_id,
in_reply_to_comment_id: last_comment_id,
origin_comment_id:
origin_comment_id || previous_in_reply_to_comment_id || last_comment_id
},
true
) do
Comments.create_discussion(%{
text: text,
discussion_id: discussion_id,
actor_id: creator_id,
attributed_to_id: actor_id,
in_reply_to_comment_id: last_comment_id,
origin_comment_id:
origin_comment_id || previous_in_reply_to_comment_id || last_comment_id
}) do
{:ok, discussion}
end
end

View File

@@ -96,8 +96,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
# TODO Move me to somewhere cleaner
defp save_attached_pictures(args) do
Enum.reduce([:avatar, :banner], args, fn key, args ->
if Map.has_key?(args, key) && !is_nil(args[key][:picture]) do
pic = args[key][:picture]
if Map.has_key?(args, key) && !is_nil(args[key][:media]) do
pic = args[key][:media]
with {:ok, %{name: name, url: url, content_type: content_type, size: _size}} <-
Upload.store(pic.file, type: key, description: pic.alt) do

View File

@@ -1,50 +1,47 @@
defmodule Mobilizon.GraphQL.Resolvers.Picture do
defmodule Mobilizon.GraphQL.Resolvers.Media do
@moduledoc """
Handles the picture-related GraphQL calls
Handles the media-related GraphQL calls
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.{Media, Users}
alias Mobilizon.Media.Picture
alias Mobilizon.{Medias, Users}
alias Mobilizon.Medias.Media
alias Mobilizon.Users.User
import Mobilizon.Web.Gettext
@doc """
Get picture for an event
Get media for an event
See Mobilizon.Web.Resolvers.Event.create_event/3
"""
def picture(%{picture_id: picture_id} = _parent, _args, _resolution) do
with {:ok, picture} <- do_fetch_picture(picture_id), do: {:ok, picture}
def media(%{picture_id: media_id} = _parent, _args, _resolution) do
with {:ok, media} <- do_fetch_media(media_id), do: {:ok, media}
end
def picture(%{picture: picture} = _parent, _args, _resolution), do: {:ok, picture}
def picture(_parent, %{id: picture_id}, _resolution), do: do_fetch_picture(picture_id)
def picture(_parent, _args, _resolution), do: {:ok, nil}
def media(%{picture: media} = _parent, _args, _resolution), do: {:ok, media}
def media(_parent, %{id: media_id}, _resolution), do: do_fetch_media(media_id)
def media(_parent, _args, _resolution), do: {:ok, nil}
@spec do_fetch_picture(nil) :: {:error, nil}
defp do_fetch_picture(nil), do: {:error, nil}
def medias(%{media: medias}, _args, _resolution) do
{:ok, Enum.map(medias, &transform_media/1)}
end
@spec do_fetch_picture(String.t()) :: {:ok, Picture.t()} | {:error, :not_found}
defp do_fetch_picture(picture_id) do
case Media.get_picture(picture_id) do
%Picture{id: id, file: file} ->
{:ok,
%{
name: file.name,
url: file.url,
id: id,
content_type: file.content_type,
size: file.size
}}
@spec do_fetch_media(nil) :: {:error, nil}
defp do_fetch_media(nil), do: {:error, nil}
@spec do_fetch_media(String.t()) :: {:ok, Media.t()} | {:error, :not_found}
defp do_fetch_media(media_id) do
case Medias.get_media(media_id) do
%Media{} = media ->
{:ok, transform_media(media)}
nil ->
{:error, :not_found}
end
end
@spec upload_picture(map, map, map) :: {:ok, Picture.t()} | {:error, any}
def upload_picture(
@spec upload_media(map, map, map) :: {:ok, Media.t()} | {:error, any}
def upload_media(
_parent,
%{file: %Plug.Upload{} = file} = args,
%{context: %{current_user: %User{} = user}}
@@ -57,16 +54,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
|> Map.put(:url, url)
|> Map.put(:size, size)
|> Map.put(:content_type, content_type),
{:ok, picture = %Picture{}} <-
Media.create_picture(%{"file" => args, "actor_id" => actor_id}) do
{:ok,
%{
name: picture.file.name,
url: picture.file.url,
id: picture.id,
content_type: picture.file.content_type,
size: picture.file.size
}}
{:ok, media = %Media{}} <-
Medias.create_media(%{"file" => args, "actor_id" => actor_id}) do
{:ok, transform_media(media)}
else
{:error, :mime_type_not_allowed} ->
{:error, dgettext("errors", "File doesn't have an allowed MIME type.")}
@@ -76,28 +66,28 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
end
end
def upload_picture(_parent, _args, _resolution), do: {:error, :unauthenticated}
def upload_media(_parent, _args, _resolution), do: {:error, :unauthenticated}
@doc """
Remove a picture that the user owns
Remove a media that the user owns
"""
@spec remove_picture(map(), map(), map()) ::
{:ok, Picture.t()}
@spec remove_media(map(), map(), map()) ::
{:ok, Media.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)},
def remove_media(_parent, %{id: media_id}, %{context: %{current_user: %User{} = user}}) do
with {:media, %Media{actor_id: actor_id} = media} <-
{:media, Medias.get_media(media_id)},
{:is_owned, %Actor{} = _actor} <- User.owns_actor(user, actor_id) do
Media.delete_picture(picture)
Medias.delete_media(media)
else
{:picture, nil} -> {:error, :not_found}
{:media, nil} -> {:error, :not_found}
{:is_owned, _} -> {:error, :unauthorized}
end
end
def remove_picture(_parent, _args, _resolution), do: {:error, :unauthenticated}
def remove_media(_parent, _args, _resolution), do: {:error, :unauthenticated}
@doc """
Return the total media size for an actor
@@ -108,7 +98,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
context: %{current_user: %User{} = user}
}) do
if can_get_actor_size?(user, actor_id) do
{:ok, Media.media_size_for_actor(actor_id)}
{:ok, Medias.media_size_for_actor(actor_id)}
else
{:error, :unauthorized}
end
@@ -125,7 +115,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
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)}
{:ok, Medias.media_size_for_user(user_id)}
else
{:error, :unauthorized}
end
@@ -133,6 +123,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
def user_size(_parent, _args, _resolution), do: {:error, :unauthenticated}
@spec transform_media(Media.t()) :: map()
defp transform_media(%Media{id: id, file: file}) do
%{
name: file.name,
url: file.url,
id: id,
content_type: file.content_type,
size: file.size
}
end
@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))

View File

@@ -13,6 +13,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
import Mobilizon.Web.Gettext
alias Mobilizon.Federation.ActivityPub
require Logger
alias Mobilizon.Web.{MediaProxy, Upload}
@@ -137,6 +138,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
%{id: id} = args,
%{context: %{current_user: user}} = _resolution
) do
require Logger
args = Map.put(args, :user_id, user.id)
with {:find_actor, %Actor{} = actor} <-
@@ -198,11 +200,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
defp save_attached_pictures(args) do
Enum.reduce([:avatar, :banner], args, fn key, args ->
if Map.has_key?(args, key) && !is_nil(args[key][:picture]) do
pic = args[key][:picture]
if Map.has_key?(args, key) && !is_nil(args[key][:media]) do
media = args[key][:media]
with {:ok, %{name: name, url: url, content_type: content_type, size: _size}} <-
Upload.store(pic.file, type: key, description: pic.alt) do
Upload.store(media.file, type: key, description: media.alt) do
Map.put(args, key, %{"name" => name, "url" => url, "mediaType" => content_type})
end
else

View File

@@ -116,6 +116,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
Map.update(args, :picture, nil, fn picture ->
process_picture(picture, group)
end),
args <- extract_pictures_from_post_body(args, actor_id),
{:ok, _, %Post{} = post} <-
ActivityPub.create(
:post,
@@ -156,6 +157,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
Map.update(args, :picture, nil, fn picture ->
process_picture(picture, group)
end),
args <- extract_pictures_from_post_body(args, actor_id),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Post{} = post} <-
ActivityPub.update(post, args, true, %{"actor" => actor_url}) do
@@ -210,15 +212,23 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
end
defp process_picture(nil, _), do: nil
defp process_picture(%{picture_id: _picture_id} = args, _), do: args
defp process_picture(%{media_id: _picture_id} = args, _), do: args
defp process_picture(%{picture: picture}, %Actor{id: actor_id}) do
defp process_picture(%{media: media}, %Actor{id: actor_id}) do
%{
file:
picture
media
|> Map.get(:file)
|> Utils.make_picture_data(description: Map.get(picture, :name)),
|> Utils.make_media_data(description: Map.get(media, :name)),
actor_id: actor_id
}
end
@spec extract_pictures_from_post_body(map(), String.t()) :: map()
defp extract_pictures_from_post_body(%{body: body} = args, actor_id) do
pictures = Mobilizon.GraphQL.API.Utils.extract_pictures_from_body(body, actor_id)
Map.put(args, :media, pictures)
end
defp extract_pictures_from_post_body(args, _actor_id), do: args
end

View File

@@ -529,7 +529,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
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)
%{elements: elements, total: total} = Mobilizon.Medias.medias_for_user(user_id, page, limit)
{:ok,
%{

View File

@@ -30,7 +30,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_types(Schema.Custom.Point)
import_types(Schema.UserType)
import_types(Schema.PictureType)
import_types(Schema.MediaType)
import_types(Schema.ActorInterface)
import_types(Schema.Actors.PersonType)
import_types(Schema.Actors.GroupType)
@@ -145,7 +145,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:tag_queries)
import_fields(:address_queries)
import_fields(:config_queries)
import_fields(:picture_queries)
import_fields(:media_queries)
import_fields(:report_queries)
import_fields(:admin_queries)
import_fields(:todo_list_queries)
@@ -168,7 +168,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:participant_mutations)
import_fields(:member_mutations)
import_fields(:feed_token_mutations)
import_fields(:picture_mutations)
import_fields(:media_mutations)
import_fields(:report_mutations)
import_fields(:admin_mutations)
import_fields(:todo_list_mutations)

View File

@@ -28,8 +28,8 @@ defmodule Mobilizon.GraphQL.Schema.ActorInterface do
field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
field(:avatar, :media, description: "The actor's avatar media")
field(:banner, :media, description: "The actor's banner media")
# These one should have a privacy setting
field(:following, list_of(:follower), description: "List of followings")

View File

@@ -3,7 +3,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
Schema representation for Group.
"""
alias Mobilizon.GraphQL.Resolvers.Picture
alias Mobilizon.GraphQL.Resolvers.Media
use Absinthe.Schema.Notation
@desc """
@@ -27,8 +27,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
field(:avatar, :media, description: "The actor's avatar media")
field(:banner, :media, description: "The actor's banner media")
# These one should have a privacy setting
field(:following, list_of(:follower), description: "List of followings")
@@ -37,7 +37,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
field(:followingCount, :integer, description: "Number of actors following this actor")
field(:media_size, :integer,
resolve: &Picture.actor_size/3,
resolve: &Media.actor_size/3,
description: "The total size of the media from this actor"
)
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, Picture, Post, Resource, Todos}
alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Media, Member, Post, Resource, Todos}
alias Mobilizon.GraphQL.Schema
import_types(Schema.Actors.MemberType)
@@ -38,8 +38,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
field(:avatar, :media, description: "The actor's avatar media")
field(:banner, :media, description: "The actor's banner media")
field(:physical_address, :address,
resolve: dataloader(Addresses),
@@ -53,7 +53,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
field(:followingCount, :integer, description: "Number of actors following this actor")
field(:media_size, :integer,
resolve: &Picture.actor_size/3,
resolve: &Media.actor_size/3,
description: "The total size of the media from this actor"
)
@@ -198,14 +198,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
default_value: :public
)
arg(:avatar, :picture_input,
arg(:avatar, :media_input,
description:
"The avatar for the group, either as an object or directly the ID of an existing Picture"
"The avatar for the group, either as an object or directly the ID of an existing media"
)
arg(:banner, :picture_input,
arg(:banner, :media_input,
description:
"The banner for the group, either as an object or directly the ID of an existing Picture"
"The banner for the group, either as an object or directly the ID of an existing media"
)
arg(:physical_address, :address_input, description: "The physical address for the group")
@@ -226,14 +226,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
description: "Whether the group can be join freely, with approval or is invite-only."
)
arg(:avatar, :picture_input,
arg(:avatar, :media_input,
description:
"The avatar for the group, either as an object or directly the ID of an existing Picture"
"The avatar for the group, either as an object or directly the ID of an existing media"
)
arg(:banner, :picture_input,
arg(:banner, :media_input,
description:
"The banner for the group, either as an object or directly the ID of an existing Picture"
"The banner for the group, either as an object or directly the ID of an existing media"
)
arg(:physical_address, :address_input, description: "The physical address for the group")

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, Picture}
alias Mobilizon.GraphQL.Resolvers.{Media, Person}
alias Mobilizon.GraphQL.Schema
import_types(Schema.Events.FeedTokenType)
@@ -40,8 +40,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
field(:avatar, :media, description: "The actor's avatar media")
field(:banner, :media, description: "The actor's banner media")
# These one should have a privacy setting
field(:following, list_of(:follower), description: "List of followings")
@@ -50,7 +50,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
field(:followingCount, :integer, description: "Number of actors following this actor")
field(:media_size, :integer,
resolve: &Picture.actor_size/3,
resolve: &Media.actor_size/3,
description: "The total size of the media from this actor"
)
@@ -150,14 +150,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
arg(:summary, :string, description: "The summary for the new profile", default_value: "")
arg(:avatar, :picture_input,
arg(:avatar, :media_input,
description:
"The avatar for the profile, either as an object or directly the ID of an existing Picture"
"The avatar for the profile, either as an object or directly the ID of an existing media"
)
arg(:banner, :picture_input,
arg(:banner, :media_input,
description:
"The banner for the profile, either as an object or directly the ID of an existing Picture"
"The banner for the profile, either as an object or directly the ID of an existing media"
)
resolve(&Person.create_person/3)
@@ -171,14 +171,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
arg(:summary, :string, description: "The summary for this profile")
arg(:avatar, :picture_input,
arg(:avatar, :media_input,
description:
"The avatar for the profile, either as an object or directly the ID of an existing Picture"
"The avatar for the profile, either as an object or directly the ID of an existing media"
)
arg(:banner, :picture_input,
arg(:banner, :media_input,
description:
"The banner for the profile, either as an object or directly the ID of an existing Picture"
"The banner for the profile, either as an object or directly the ID of an existing media"
)
resolve(&Person.update_person/3)
@@ -200,14 +200,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
arg(:summary, :string, description: "The summary for the new profile", default_value: "")
arg(:email, non_null(:string), description: "The email from the user previously created")
arg(:avatar, :picture_input,
arg(:avatar, :media_input,
description:
"The avatar for the profile, either as an object or directly the ID of an existing Picture"
"The avatar for the profile, either as an object or directly the ID of an existing media"
)
arg(:banner, :picture_input,
arg(:banner, :media_input,
description:
"The banner for the profile, either as an object or directly the ID of an existing Picture"
"The banner for the profile, either as an object or directly the ID of an existing media"
)
resolve(&Person.register_person/3)

View File

@@ -43,7 +43,6 @@ defmodule Mobilizon.GraphQL.Schema.AddressType do
An address input
"""
input_object :address_input do
# Either a full picture object
field(:geom, :point, description: "The geocoordinates for the point where this address is")
field(:street, :string, description: "The address's street name (with number)")
field(:locality, :string, description: "The address's locality")

View File

@@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.{Actors, Addresses, Discussions}
alias Mobilizon.GraphQL.Resolvers.{Event, Picture, Tag}
alias Mobilizon.GraphQL.Resolvers.{Event, Media, Tag}
alias Mobilizon.GraphQL.Schema
import_types(Schema.AddressType)
@@ -31,9 +31,14 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:visibility, :event_visibility, description: "The event's visibility")
field(:join_options, :event_join_options, description: "The event's visibility")
field(:picture, :picture,
field(:picture, :media,
description: "The event's picture",
resolve: &Picture.picture/3
resolve: &Media.media/3
)
field(:media, list_of(:media),
description: "The event's media",
resolve: &Media.medias/3
)
field(:publish_at, :datetime, description: "When the event was published")
@@ -328,9 +333,9 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
description: "The list of tags associated to the event"
)
arg(:picture, :picture_input,
arg(:picture, :media_input,
description:
"The picture for the event, either as an object or directly the ID of an existing Picture"
"The picture for the event, either as an object or directly the ID of an existing media"
)
arg(:publish_at, :datetime, description: "Datetime when the event was published")
@@ -379,9 +384,9 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
arg(:tags, list_of(:string), description: "The list of tags associated to the event")
arg(:picture, :picture_input,
arg(:picture, :media_input,
description:
"The picture for the event, either as an object or directly the ID of an existing Picture"
"The picture for the event, either as an object or directly the ID of an existing media"
)
arg(:online_address, :string, description: "Online address of the event")

View File

@@ -0,0 +1,68 @@
defmodule Mobilizon.GraphQL.Schema.MediaType do
@moduledoc """
Schema representation for Medias
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.Media
@desc "A media"
object :media do
field(:id, :id, description: "The media's ID")
field(:alt, :string, description: "The media's alternative text")
field(:name, :string, description: "The media's name")
field(:url, :string, description: "The media's full URL")
field(:content_type, :string, description: "The media's detected content type")
field(:size, :integer, description: "The media's size")
end
@desc """
A paginated list of medias
"""
object :paginated_media_list do
field(:elements, list_of(:media), description: "The list of medias")
field(:total, :integer, description: "The total number of medias in the list")
end
@desc "An attached media or a link to a media"
input_object :media_input do
# Either a full media object
field(:media, :media_input_object, description: "A full media attached")
# Or directly the ID of an existing media
field(:media_id, :id, description: "The ID of an existing media")
end
@desc "An attached media"
input_object :media_input_object do
field(:name, non_null(:string), description: "The media's name")
field(:alt, :string, description: "The media's alternative text")
field(:file, non_null(:upload), description: "The media file")
field(:actor_id, :id, description: "The media owner")
end
object :media_queries do
@desc "Get a media"
field :media, :media do
arg(:id, non_null(:id), description: "The media ID")
resolve(&Media.media/3)
end
end
object :media_mutations do
@desc "Upload a media"
field :upload_media, :media do
arg(:name, non_null(:string), description: "The media's name")
arg(:alt, :string, description: "The media's alternative text")
arg(:file, non_null(:upload), description: "The media file")
resolve(&Media.upload_media/3)
end
@desc """
Remove a media
"""
field :remove_media, :deleted_object do
arg(:id, non_null(:id), description: "The media's ID")
resolve(&Media.remove_media/3)
end
end
end

View File

@@ -1,68 +0,0 @@
defmodule Mobilizon.GraphQL.Schema.PictureType do
@moduledoc """
Schema representation for Pictures
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.Picture
@desc "A picture"
object :picture do
field(:id, :id, description: "The picture's ID")
field(:alt, :string, description: "The picture's alternative text")
field(:name, :string, description: "The picture's name")
field(:url, :string, description: "The picture's full URL")
field(:content_type, :string, description: "The picture's detected content type")
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
field(:picture, :picture_input_object, description: "A full picture attached")
# Or directly the ID of an existing picture
field(:picture_id, :id, description: "The ID of an existing picture")
end
@desc "An attached picture"
input_object :picture_input_object do
field(:name, non_null(:string), description: "The picture's name")
field(:alt, :string, description: "The picture's alternative text")
field(:file, non_null(:upload), description: "The picture file")
field(:actor_id, :id, description: "The picture owner")
end
object :picture_queries do
@desc "Get a picture"
field :picture, :picture do
arg(:id, non_null(:id), description: "The picture ID")
resolve(&Picture.picture/3)
end
end
object :picture_mutations do
@desc "Upload a picture"
field :upload_picture, :picture do
arg(:name, non_null(:string), description: "The picture's name")
arg(:alt, :string, description: "The picture's alternative text")
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

@@ -3,7 +3,7 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
Schema representation for Posts
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.{Picture, Post, Tag}
alias Mobilizon.GraphQL.Resolvers.{Media, Post, Tag}
@desc "A post"
object :post do
@@ -25,9 +25,9 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
description: "The post's tags"
)
field(:picture, :picture,
description: "The posts's picture",
resolve: &Picture.picture/3
field(:picture, :media,
description: "The posts's media",
resolve: &Media.media/3
)
end
@@ -76,9 +76,9 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
description: "The list of tags associated to the post"
)
arg(:picture, :picture_input,
arg(:picture, :media_input,
description:
"The banner for the post, either as an object or directly the ID of an existing Picture"
"The banner for the post, either as an object or directly the ID of an existing media"
)
resolve(&Post.create_post/3)
@@ -99,9 +99,9 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
arg(:tags, list_of(:string), description: "The list of tags associated to the post")
arg(:picture, :picture_input,
arg(:picture, :media_input,
description:
"The banner for the post, either as an object or directly the ID of an existing Picture"
"The banner for the post, either as an object or directly the ID of an existing media"
)
resolve(&Post.update_post/3)

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.{Picture, User}
alias Mobilizon.GraphQL.Resolvers.{Media, User}
alias Mobilizon.GraphQL.Schema
import_types(Schema.SortType)
@@ -111,7 +111,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
description: "The IP adress the user's currently signed-in with"
)
field(:media, :paginated_picture_list, description: "The user's media objects") do
field(:media, :paginated_media_list, description: "The user's media objects") do
arg(:page, :integer,
default_value: 1,
description: "The page in the paginated user media list"
@@ -122,7 +122,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
end
field(:media_size, :integer,
resolve: &Picture.user_size/3,
resolve: &Media.user_size/3,
description: "The total size of all the media from this user (from all their actors)"
)
end

View File

@@ -47,12 +47,14 @@ defmodule Mix.Tasks.Mobilizon.Common do
else: shell_prompt(message, "Continue?") in ~w(Yn Y y)
end
@spec shell_info(String.t()) :: :ok
def shell_info(message) do
if mix_shell?(),
do: Mix.shell().info(message),
else: IO.puts(message)
end
@spec shell_error(String.t()) :: :ok
def shell_error(message) do
if mix_shell?(),
do: Mix.shell().error(message),

View File

@@ -0,0 +1,23 @@
defmodule Mix.Tasks.Mobilizon.Maintenance do
@moduledoc """
Tasks to maintain mobilizon
"""
use Mix.Task
alias Mix.Tasks
import Mix.Tasks.Mobilizon.Common
@shortdoc "List common Mobilizon maintenance tasks"
@impl Mix.Task
def run(_) do
shell_info("\nAvailable tasks:")
if mix_shell?() do
Tasks.Help.run(["--search", "mobilizon.maintenance."])
else
show_subtasks_for_module(__MODULE__)
end
end
end

View File

@@ -0,0 +1,107 @@
defmodule Mix.Tasks.Mobilizon.Maintenance.FixUnattachedMediaInBody do
@moduledoc """
Task to reattach media files that were added in event, post or comment bodies without being attached to their entities.
This task should only be run once.
"""
use Mix.Task
import Mix.Tasks.Mobilizon.Common
alias Mobilizon.{Discussions, Events, Medias, Posts}
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Posts.Post
alias Mobilizon.Storage.Repo
require Logger
@preferred_cli_env "prod"
# TODO: Remove me in Mobilizon 1.2
@shortdoc "Reattaches inline media from events and posts"
def run([]) do
start_mobilizon()
shell_info("Going to extract pictures from events")
extract_inline_pictures_from_bodies(Event)
shell_info("Going to extract pictures from posts")
extract_inline_pictures_from_bodies(Post)
shell_info("Going to extract pictures from comments")
extract_inline_pictures_from_bodies(Comment)
end
defp extract_inline_pictures_from_bodies(entity) do
Repo.transaction(
fn ->
entity
|> Repo.stream()
|> Stream.map(&extract_pictures(&1))
|> Stream.map(fn {entity, pics} -> save_entity(entity, pics) end)
|> Stream.run()
end,
timeout: :infinity
)
end
defp extract_pictures(entity) do
extracted_pictures = entity |> get_body() |> parse_body() |> get_media_entities_from_urls()
attached_picture = entity |> get_picture() |> get_media_entity_from_media_id()
attached_pictures = [attached_picture] |> Enum.filter(& &1)
{entity, extracted_pictures ++ attached_pictures}
end
defp get_body(%Event{description: description}), do: description
defp get_body(%Post{body: body}), do: body
defp get_body(%Comment{text: text}), do: text
defp get_picture(%Event{picture_id: picture_id}), do: picture_id
defp get_picture(%Post{picture_id: picture_id}), do: picture_id
defp get_picture(%Comment{}), do: nil
defp parse_body(nil), do: []
defp parse_body(body) do
with res <- Regex.scan(~r/<img\s[^>]*?src\s*=\s*['\"]([^'\"]*?)['\"][^>]*?>/, body),
res <- Enum.map(res, fn [_, res] -> res end) do
res
end
end
defp get_media_entities_from_urls(media_urls) do
media_urls
|> Enum.map(fn media_url ->
# We prefer orphan media, but fallback on already attached media just in case
Medias.get_unattached_media_by_url(media_url) || Medias.get_media_by_url(media_url)
end)
|> Enum.filter(& &1)
end
defp get_media_entity_from_media_id(nil), do: nil
defp get_media_entity_from_media_id(media_id) do
Medias.get_media(media_id)
end
defp save_entity(%Event{} = _event, []), do: :ok
defp save_entity(%Event{} = event, media) do
event = Repo.preload(event, [:contacts, :media])
Events.update_event(event, %{media: media})
end
defp save_entity(%Post{} = _post, []), do: :ok
defp save_entity(%Post{} = post, media) do
post = Repo.preload(post, [:media])
Posts.update_post(post, %{media: media})
end
defp save_entity(%Comment{} = _comment, []), do: :ok
defp save_entity(%Comment{} = comment, media) do
comment = Repo.preload(comment, [:media])
Discussions.update_comment(comment, %{media: media})
end
end

View File

@@ -0,0 +1,23 @@
defmodule Mix.Tasks.Mobilizon.Media do
@moduledoc """
Tasks to manage media
"""
use Mix.Task
alias Mix.Tasks
import Mix.Tasks.Mobilizon.Common
@shortdoc "Manages Mobilizon media"
@impl Mix.Task
def run(_) do
shell_info("\nAvailable tasks:")
if mix_shell?() do
Tasks.Help.run(["--search", "mobilizon.media."])
else
show_subtasks_for_module(__MODULE__)
end
end
end

View File

@@ -0,0 +1,87 @@
defmodule Mix.Tasks.Mobilizon.Media.CleanOrphan do
@moduledoc """
Task to accept an instance follow request
"""
use Mix.Task
import Mix.Tasks.Mobilizon.Common
alias Mobilizon.Service.CleanOrphanMedia
@shortdoc "Clean orphan media"
@grace_period Mobilizon.Config.get([:instance, :orphan_upload_grace_period_hours], 48)
@impl Mix.Task
def run(options) do
{options, [], []} =
OptionParser.parse(
options,
strict: [
dry_run: :boolean,
days: :integer,
verbose: :boolean
],
aliases: [
d: :days,
v: :verbose
]
)
dry_run = Keyword.get(options, :dry_run, false)
grace_period = Keyword.get(options, :days)
grace_period = if is_nil(grace_period), do: @grace_period, else: grace_period * 24
verbose = Keyword.get(options, :verbose, false)
start_mobilizon()
case CleanOrphanMedia.clean(dry_run: dry_run, grace_period: grace_period) do
{:ok, medias} ->
if length(medias) > 0 do
if dry_run or verbose do
details(medias, dry_run, verbose)
end
result(dry_run, length(medias))
else
empty_result(dry_run)
end
:ok
_err ->
shell_error("Error while cleaning orphan media files")
end
end
@spec details(list(Media.t()), boolean(), boolean()) :: :ok
defp details(medias, dry_run, verbose) do
cond do
dry_run ->
shell_info("List of files that would have been deleted")
verbose ->
shell_info("List of files that have been deleted")
end
Enum.each(medias, fn media ->
shell_info("ID: #{media.id}, Actor: #{media.actor_id}, URL: #{media.file.url}")
end)
end
@spec result(boolean(), boolean()) :: :ok
defp result(dry_run, nb_medias) do
if dry_run do
shell_info("#{nb_medias} files would have been deleted")
else
shell_info("#{nb_medias} files have been deleted")
end
end
@spec empty_result(boolean()) :: :ok
defp empty_result(dry_run) do
if dry_run do
shell_info("No files would have been deleted")
else
shell_info("No files were deleted")
end
end
end

View File

@@ -58,7 +58,11 @@ defmodule Mobilizon do
cachex_spec(:statistics, 10, 60, 60),
cachex_spec(:config, 10, 60, 60),
cachex_spec(:rich_media_cache, 10, 60, 60),
cachex_spec(:activity_pub, 2500, 3, 15)
cachex_spec(:activity_pub, 2500, 3, 15),
%{
id: :cache_key_value,
start: {Cachex, :start_link, [:key_value]}
}
] ++
task_children(@env)

View File

@@ -12,7 +12,7 @@ defmodule Mobilizon.Actors.Actor do
alias Mobilizon.Addresses.Address
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Media.File
alias Mobilizon.Medias.File
alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Users.User

View File

@@ -14,7 +14,7 @@ defmodule Mobilizon.Actors do
alias Mobilizon.Addresses.Address
alias Mobilizon.{Crypto, Events}
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Media.File
alias Mobilizon.Medias.File
alias Mobilizon.Service.Workers
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users
@@ -285,7 +285,7 @@ defmodule Mobilizon.Actors do
# if is_nil(file) do
# nil
# else
# struct(Mobilizon.Media.File, file)
# struct(Mobilizon.Medias.File, file)
# end
# end
@@ -1673,7 +1673,8 @@ defmodule Mobilizon.Actors do
:attributed_to,
:tags,
:physical_address,
:contacts
:contacts,
:media
])
ActivityPub.delete(event, actor, false)

View File

@@ -11,6 +11,7 @@ defmodule Mobilizon.Discussions.Comment do
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, CommentVisibility, Discussion}
alias Mobilizon.Events.{Event, Tag}
alias Mobilizon.Medias.Media
alias Mobilizon.Mention
alias Mobilizon.Web.Endpoint
@@ -27,6 +28,7 @@ defmodule Mobilizon.Discussions.Comment do
event: Event.t(),
tags: [Tag.t()],
mentions: [Mention.t()],
media: [Media.t()],
in_reply_to_comment: t,
origin_comment: t
}
@@ -66,6 +68,7 @@ defmodule Mobilizon.Discussions.Comment do
has_many(:replies, Comment, foreign_key: :in_reply_to_comment_id)
many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete)
has_many(:mentions, Mention)
many_to_many(:media, Media, join_through: "comments_medias", on_replace: :delete)
timestamps(type: :utc_datetime)
end
@@ -120,6 +123,7 @@ defmodule Mobilizon.Discussions.Comment do
|> maybe_add_published_at()
|> maybe_generate_uuid()
|> maybe_generate_url()
|> put_assoc(:media, Map.get(attrs, :media, []))
|> put_tags(attrs)
|> put_mentions(attrs)
end

View File

@@ -10,7 +10,7 @@ defmodule Mobilizon.Events.Event do
alias Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.{Addresses, Events, Media, Mention}
alias Mobilizon.{Addresses, Events, Medias, Mention}
alias Mobilizon.Addresses.Address
alias Mobilizon.Discussions.Comment
@@ -27,7 +27,7 @@ defmodule Mobilizon.Events.Event do
Track
}
alias Mobilizon.Media.Picture
alias Mobilizon.Medias.Media
alias Mobilizon.Storage.Repo
alias Mobilizon.Web.Endpoint
@@ -54,7 +54,8 @@ defmodule Mobilizon.Events.Event do
organizer_actor: Actor.t(),
attributed_to: Actor.t(),
physical_address: Address.t(),
picture: Picture.t(),
picture: Media.t(),
media: [Media.t()],
tracks: [Track.t()],
sessions: [Session.t()],
mentions: [Mention.t()],
@@ -110,7 +111,7 @@ defmodule Mobilizon.Events.Event do
belongs_to(:organizer_actor, Actor, foreign_key: :organizer_actor_id)
belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id)
belongs_to(:physical_address, Address, on_replace: :nilify)
belongs_to(:picture, Picture, on_replace: :update)
belongs_to(:picture, Media, on_replace: :update)
has_many(:tracks, Track)
has_many(:sessions, Session)
has_many(:mentions, Mention)
@@ -118,6 +119,7 @@ defmodule Mobilizon.Events.Event do
many_to_many(:contacts, Actor, join_through: "event_contacts", on_replace: :delete)
many_to_many(:tags, Tag, join_through: "events_tags", on_replace: :delete)
many_to_many(:participants, Actor, join_through: Participant)
many_to_many(:media, Media, join_through: "events_medias", on_replace: :delete)
timestamps(type: :utc_datetime)
end
@@ -150,6 +152,7 @@ defmodule Mobilizon.Events.Event do
changeset
|> cast_embed(:options)
|> put_assoc(:contacts, Map.get(attrs, :contacts, []))
|> put_assoc(:media, Map.get(attrs, :media, []))
|> put_tags(attrs)
|> put_address(attrs)
|> put_picture(attrs)
@@ -241,9 +244,9 @@ defmodule Mobilizon.Events.Event do
# In case the provided picture is an existing one
@spec put_picture(Changeset.t(), map) :: Changeset.t()
defp put_picture(%Changeset{} = changeset, %{picture: %{picture_id: id} = _picture}) do
case Media.get_picture!(id) do
%Picture{} = picture ->
defp put_picture(%Changeset{} = changeset, %{picture: %{media_id: id} = _picture}) do
case Medias.get_media!(id) do
%Media{} = picture ->
put_assoc(changeset, :picture, picture)
_ ->

View File

@@ -84,7 +84,8 @@ defmodule Mobilizon.Events do
:participants,
:physical_address,
:picture,
:contacts
:contacts,
:media
]
@doc """
@@ -295,7 +296,7 @@ defmodule Mobilizon.Events do
@spec update_event(Event.t(), map) :: {:ok, Event.t()} | {:error, Changeset.t()}
def update_event(%Event{draft: old_draft} = old_event, attrs) do
with %Changeset{changes: changes} = changeset <-
Event.update_changeset(Repo.preload(old_event, :tags), attrs),
Event.update_changeset(Repo.preload(old_event, [:tags, :media]), attrs),
{:ok, %{update: %Event{} = new_event}} <-
Multi.new()
|> Multi.update(:update, changeset)

View File

@@ -1,150 +0,0 @@
defmodule Mobilizon.Media do
@moduledoc """
The Media context.
"""
import Ecto.Query
alias Ecto.Multi
alias Mobilizon.Actors.Actor
alias Mobilizon.Media.{File, Picture}
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User
alias Mobilizon.Web.Upload
@doc """
Gets a single picture.
"""
@spec get_picture(integer | String.t()) :: Picture.t() | nil
def get_picture(id), do: Repo.get(Picture, id)
@doc """
Gets a single picture.
Raises `Ecto.NoResultsError` if the picture does not exist.
"""
@spec get_picture!(integer | String.t()) :: Picture.t()
def get_picture!(id), do: Repo.get!(Picture, id)
@doc """
Get a picture by its URL.
"""
@spec get_picture_by_url(String.t()) :: Picture.t() | nil
def get_picture_by_url(url) do
url
|> picture_by_url_query()
|> 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.
"""
@spec create_picture(map) :: {:ok, Picture.t()} | {:error, Ecto.Changeset.t()}
def create_picture(attrs \\ %{}) do
%Picture{}
|> Picture.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a picture.
"""
@spec update_picture(Picture.t(), map) :: {:ok, Picture.t()} | {:error, Ecto.Changeset.t()}
def update_picture(%Picture{} = picture, attrs) do
picture
|> Picture.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a picture.
"""
@spec delete_picture(Picture.t()) :: {:ok, Picture.t()} | {:error, Ecto.Changeset.t()}
def delete_picture(%Picture{} = picture) do
transaction =
Multi.new()
|> Multi.delete(:picture, picture)
|> Multi.run(:remove, fn _repo, %{picture: %Picture{file: %File{url: url}}} ->
Upload.remove(url)
end)
|> Repo.transaction()
case transaction do
{:ok, %{picture: %Picture{} = picture}} ->
{:ok, picture}
{:error, :remove, error, _} ->
{:error, error}
end
end
@spec picture_by_url_query(String.t()) :: Ecto.Query.t()
defp picture_by_url_query(url) do
from(
p in Picture,
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

@@ -1,32 +0,0 @@
defmodule Mobilizon.Media.Picture do
@moduledoc """
Represents a picture entity.
"""
use Ecto.Schema
import Ecto.Changeset, only: [cast: 3, cast_embed: 2]
alias Mobilizon.Actors.Actor
alias Mobilizon.Media.File
@type t :: %__MODULE__{
file: File.t(),
actor: Actor.t()
}
schema "pictures" do
embeds_one(:file, File, on_replace: :update)
belongs_to(:actor, Actor)
timestamps()
end
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = picture, attrs) do
picture
|> cast(attrs, [:actor_id])
|> cast_embed(:file)
end
end

View File

@@ -1,4 +1,4 @@
defmodule Mobilizon.Media.File do
defmodule Mobilizon.Medias.File do
@moduledoc """
Represents a file entity.
"""

View File

@@ -0,0 +1,40 @@
defmodule Mobilizon.Medias.Media do
@moduledoc """
Represents a media entity.
"""
use Ecto.Schema
import Ecto.Changeset, only: [cast: 3, cast_embed: 2]
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Medias.File
alias Mobilizon.Posts.Post
@type t :: %__MODULE__{
file: File.t(),
actor: Actor.t()
}
schema "medias" do
embeds_one(:file, File, on_replace: :update)
belongs_to(:actor, Actor)
has_many(:event_picture, Event, foreign_key: :picture_id)
many_to_many(:events, Event, join_through: "events_medias")
has_many(:posts_picture, Post, foreign_key: :picture_id)
many_to_many(:posts, Post, join_through: "posts_medias")
many_to_many(:comments, Comment, join_through: "comments_medias")
timestamps()
end
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = media, attrs) do
media
|> cast(attrs, [:actor_id])
|> cast_embed(:file)
end
end

View File

@@ -0,0 +1,184 @@
defmodule Mobilizon.Medias do
@moduledoc """
The Media context.
"""
import Ecto.Query
alias Ecto.Multi
alias Mobilizon.Actors.Actor
alias Mobilizon.Medias.{File, Media}
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User
alias Mobilizon.Web.Upload
require Logger
@doc """
Gets a single media.
"""
@spec get_media(integer | String.t()) :: Media.t() | nil
def get_media(id), do: Repo.get(Media, id)
@doc """
Gets a single media.
Raises `Ecto.NoResultsError` if the media does not exist.
"""
@spec get_media!(integer | String.t()) :: Media.t()
def get_media!(id), do: Repo.get!(Media, id)
@doc """
Get a media by its URL.
"""
@spec get_media_by_url(String.t()) :: Media.t() | nil
def get_media_by_url(url) do
url
|> media_by_url_query()
|> limit(1)
|> Repo.one()
end
@doc """
Get an unattached media by it's URL
"""
def get_unattached_media_by_url(url) do
url
|> media_by_url_query()
|> join(:left, [m], e in assoc(m, :events))
|> join(:left, [m], ep in assoc(m, :event_picture))
|> join(:left, [m], p in assoc(m, :posts))
|> join(:left, [m], pp in assoc(m, :posts_picture))
|> join(:left, [m], c in assoc(m, :comments))
|> where([_m, e], is_nil(e.id))
|> where([_m, _e, ep], is_nil(ep.id))
|> where([_m, _e, _ep, p], is_nil(p.id))
|> where([_m, _e, _ep, _p, pp], is_nil(pp.id))
|> where([_m, _e, _ep, _p, _pp, c], is_nil(c.id))
|> limit(1)
|> Repo.one()
end
@doc """
List the paginated media for an actor
"""
@spec medias_for_actor(integer | String.t(), integer | nil, integer | nil) :: Page.t()
def medias_for_actor(actor_id, page, limit) do
actor_id
|> medias_for_actor_query()
|> Page.build_page(page, limit)
end
@doc """
List the paginated media for user
"""
@spec medias_for_user(integer | String.t(), integer | nil, integer | nil) :: Page.t()
def medias_for_user(user_id, page, limit) do
user_id
|> medias_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
|> medias_for_actor_query()
|> select([:file])
|> Repo.all()
|> Enum.map(& &1.file.size)
|> Enum.filter(& &1)
|> 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
|> medias_for_user_query()
|> select([:file])
|> Repo.all()
|> Enum.map(& &1.file.size)
|> Enum.sum()
end
@doc """
Creates a media.
"""
@spec create_media(map) :: {:ok, Media.t()} | {:error, Ecto.Changeset.t()}
def create_media(attrs \\ %{}) do
%Media{}
|> Media.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a media.
"""
@spec update_media(Media.t(), map) :: {:ok, Media.t()} | {:error, Ecto.Changeset.t()}
def update_media(%Media{} = media, attrs) do
media
|> Media.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a media.
"""
@spec delete_media(Media.t()) :: {:ok, Media.t()} | {:error, Ecto.Changeset.t()}
def delete_media(%Media{} = media, opts \\ []) do
transaction =
Multi.new()
|> Multi.delete(:media, media)
|> Multi.run(:remove, fn _repo, %{media: %Media{file: %File{url: url}} = media} ->
case Upload.remove(url) do
{:error, err} ->
if err =~ "doesn't exist" and Keyword.get(opts, :ignore_file_not_found, false) do
Logger.info("Deleting media and ignoring absent file.")
{:ok, media}
else
{:error, err}
end
{:ok, media} ->
{:ok, media}
end
end)
|> Repo.transaction()
case transaction do
{:ok, %{media: %Media{} = media}} ->
{:ok, media}
{:error, :remove, error, _} ->
{:error, error}
end
end
@spec media_by_url_query(String.t()) :: Ecto.Query.t()
defp media_by_url_query(url) do
from(
p in Media,
where: fragment("? @> ?", p.file, ~s|{"url": "#{url}"}|)
)
end
@spec medias_for_actor_query(integer() | String.t()) :: Ecto.Query.t()
defp medias_for_actor_query(actor_id) do
Media
|> join(:inner, [p], a in Actor, on: p.actor_id == a.id)
|> where([_p, a], a.id == ^actor_id)
end
@spec medias_for_user_query(integer() | String.t()) :: Ecto.Query.t()
defp medias_for_user_query(user_id) do
Media
|> 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

@@ -22,8 +22,8 @@ defmodule Mobilizon.Posts.Post do
alias Ecto.Changeset
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Tag
alias Mobilizon.Media
alias Mobilizon.Media.Picture
alias Mobilizon.Medias
alias Mobilizon.Medias.Media
alias Mobilizon.Posts.Post.TitleSlug
alias Mobilizon.Posts.PostVisibility
alias Mobilizon.Web.Endpoint
@@ -41,7 +41,8 @@ defmodule Mobilizon.Posts.Post do
publish_at: DateTime.t(),
author: Actor.t(),
attributed_to: Actor.t(),
picture: Picture.t(),
picture: Media.t(),
media: [Media.t()],
tags: [Tag.t()]
}
@@ -60,6 +61,7 @@ defmodule Mobilizon.Posts.Post do
belongs_to(:attributed_to, Actor)
belongs_to(:picture, Picture, on_replace: :update)
many_to_many(:tags, Tag, join_through: "posts_tags", on_replace: :delete)
many_to_many(:media, Media, join_through: "posts_medias", on_replace: :delete)
timestamps(type: :utc_datetime)
end
@@ -82,6 +84,7 @@ defmodule Mobilizon.Posts.Post do
post
|> cast(attrs, @attrs)
|> maybe_generate_id()
|> put_assoc(:media, Map.get(attrs, :media, []))
|> put_tags(attrs)
|> maybe_put_publish_date()
|> put_picture(attrs)
@@ -146,8 +149,8 @@ defmodule Mobilizon.Posts.Post do
# In case the provided picture is an existing one
@spec put_picture(Changeset.t(), map) :: Changeset.t()
defp put_picture(%Changeset{} = changeset, %{picture: %{picture_id: id} = _picture}) do
case Media.get_picture!(id) do
%Picture{} = picture ->
case Medias.get_media!(id) do
%Media{} = picture ->
put_assoc(changeset, :picture, picture)
_ ->

View File

@@ -103,7 +103,7 @@ defmodule Mobilizon.Posts do
@spec update_post(Post.t(), map) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()}
def update_post(%Post{} = post, attrs) do
post
|> Repo.preload(:tags)
|> Repo.preload([:tags, :media])
|> Post.changeset(attrs)
|> Repo.update()
end

View File

@@ -0,0 +1,60 @@
defmodule Mobilizon.Service.CleanOrphanMedia do
@moduledoc """
Service to clean orphan media
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Medias
alias Mobilizon.Medias.Media
alias Mobilizon.Storage.Repo
import Ecto.Query
@grace_period Mobilizon.Config.get([:instance, :orphan_upload_grace_period_hours], 48)
@doc """
Clean orphan media
Remove media that is not attached to an entity, such as media uploads that were never used in entities.
Options:
* `grace_period` how old in hours can the media be before it's taken into account for deletion
* `dry_run` just return the media that would have been deleted, don't actually delete it
"""
@spec clean(Keyword.t()) :: {:ok, list(Media.t())} | {:error, String.t()}
def clean(opts \\ []) do
medias = find_media(opts)
if Keyword.get(opts, :dry_run, false) do
{:ok, medias}
else
Enum.each(medias, fn media ->
Medias.delete_media(media, ignore_file_not_found: true)
end)
{:ok, medias}
end
end
@spec find_media(Keyword.t()) :: list(Media.t())
defp find_media(opts) do
grace_period = Keyword.get(opts, :grace_period, @grace_period)
expiration_date = DateTime.add(DateTime.utc_now(), grace_period * -3600)
Media
|> where([m], m.inserted_at < ^expiration_date)
|> join(:inner, [m], a in Actor)
|> where([_m, a], is_nil(a.domain))
|> join(:left, [m], e in assoc(m, :events))
|> join(:left, [m], ep in assoc(m, :event_picture))
|> join(:left, [m], p in assoc(m, :posts))
|> join(:left, [m], pp in assoc(m, :posts_picture))
|> join(:left, [m], c in assoc(m, :comments))
|> where([_m, _a, e], is_nil(e.id))
|> where([_m, _a, _e, ep], is_nil(ep.id))
|> where([_m, _a, _e, _ep, p], is_nil(p.id))
|> where([_m, _a, _e, _ep, _p, pp], is_nil(pp.id))
|> where([_m, _a, _e, _ep, _p, _pp, c], is_nil(c.id))
|> distinct(true)
|> Repo.all()
end
end

View File

@@ -0,0 +1,31 @@
defmodule Mobilizon.Service.Workers.CleanOrphanMediaWorker do
@moduledoc """
Worker to clean orphan media
"""
use Oban.Worker, queue: "background"
alias Mobilizon.Service.CleanOrphanMedia
@grace_period Mobilizon.Config.get([:instance, :orphan_upload_grace_period_hours], 48)
@impl Oban.Worker
def perform(%Job{}) do
if Mobilizon.Config.get!([:instance, :remove_orphan_uploads]) and should_perform?() do
CleanOrphanMedia.clean()
end
end
@spec should_perform? :: boolean()
defp should_perform? do
case Cachex.get(:key_value, "last_media_cleanup") do
{:ok, %DateTime{} = last_media_cleanup} ->
DateTime.compare(
last_media_cleanup,
DateTime.add(DateTime.utc_now(), @grace_period * -3600)
) == :lt
_ ->
true
end
end
end

View File

@@ -72,7 +72,10 @@ defmodule Mobilizon.Web.Plugs.UploadedMedia do
conn
else
conn
|> send_resp(404, "Not found")
|> delete_resp_header("content-disposition")
|> put_status(404)
|> Phoenix.Controller.put_view(Mobilizon.Web.ErrorView)
|> Phoenix.Controller.render("404.html")
|> halt()
end
end

View File

@@ -1,6 +1,6 @@
defmodule Mobilizon.Web.Upload.Filter.Optimize do
@moduledoc """
Handle picture optimizations
Handle media optimizations
"""
@behaviour Mobilizon.Web.Upload.Filter