diff --git a/js/src/views/Posts/Edit.vue b/js/src/views/Posts/Edit.vue index 7d6428e74..957ac2dc0 100644 --- a/js/src/views/Posts/Edit.vue +++ b/js/src/views/Posts/Edit.vue @@ -97,7 +97,7 @@ import { FETCH_POST, CREATE_POST, UPDATE_POST, DELETE_POST } from "../../graphql import { IPost, PostVisibility } from "../../types/post.model"; import Editor from "../../components/Editor.vue"; -import { IActor, IGroup } from "../../types/actor"; +import { IActor, IGroup, usernameWithDomain } from "../../types/actor"; import TagInput from "../../components/Event/TagInput.vue"; import RouteName from "../../router/name"; import Subtitle from "../../components/Utils/Subtitle.vue"; @@ -233,7 +233,7 @@ export default class EditPost extends Vue { if (data && this.post.attributedTo) { this.$router.push({ name: RouteName.POSTS, - params: { preferredUsername: this.post.attributedTo.preferredUsername }, + params: { preferredUsername: usernameWithDomain(this.post.attributedTo) }, }); } } diff --git a/js/src/views/Posts/List.vue b/js/src/views/Posts/List.vue index 30d515820..214704117 100644 --- a/js/src/views/Posts/List.vue +++ b/js/src/views/Posts/List.vue @@ -120,6 +120,16 @@ const POSTS_PAGE_LIMIT = 10; components: { PostElementItem, }, + metaInfo() { + return { + // if no subcomponents specify a metaInfo.title, this title will be used + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + title: this.$t("My groups") as string, + // all titles will be injected into this template + titleTemplate: "%s | Mobilizon", + }; + }, }) export default class PostList extends Vue { @Prop({ required: true, type: String }) preferredUsername!: string; diff --git a/js/src/views/Resources/ResourceFolder.vue b/js/src/views/Resources/ResourceFolder.vue index 1dcb85952..8def62dc9 100644 --- a/js/src/views/Resources/ResourceFolder.vue +++ b/js/src/views/Resources/ResourceFolder.vue @@ -114,6 +114,7 @@ :resource="localResource" :group="resource.actor" @delete="deleteResource" + @rename="handleRename" @move="handleMove" v-else /> @@ -143,7 +144,7 @@ <section class="modal-card-body"> <resource-selector :initialResource="updatedResource" - :username="resource.actor.preferredUsername" + :username="usernameWithDomain(resource.actor)" @updateResource="moveResource" @closeMoveModal="moveModal = false" /> @@ -200,6 +201,7 @@ import { Component, Mixins, Prop, Watch } from "vue-property-decorator"; import ResourceItem from "@/components/Resource/ResourceItem.vue"; import FolderItem from "@/components/Resource/FolderItem.vue"; import Draggable from "vuedraggable"; +import { RefetchQueryDescription } from "apollo-client/core/watchQueryOptions"; import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor"; import { IActor, usernameWithDomain } from "../../types/actor"; import RouteName from "../../router/name"; @@ -232,6 +234,9 @@ import ResourceSelector from "../../components/Resource/ResourceSelector.vue"; username: this.$route.params.preferredUsername, }; }, + error({ graphQLErrors }) { + this.handleErrors(graphQLErrors); + }, }, config: CONFIG, currentActor: CURRENT_ACTOR_CLIENT, @@ -291,6 +296,13 @@ export default class Resources extends Mixins(ResourceMixin) { mapServiceTypeToIcon = mapServiceTypeToIcon; + get actualPath(): string { + const path = Array.isArray(this.$route.params.path) + ? this.$route.params.path.join("/") + : this.$route.params.path || this.path; + return path[0] !== "/" ? `/${path}` : path; + } + async createResource(): Promise<void> { if (!this.resource.actor) return; try { @@ -305,34 +317,7 @@ export default class Resources extends Mixins(ResourceMixin) { this.resource.id && this.resource.id.startsWith("root_") ? null : this.resource.id, type: this.newResource.type, }, - update: (store, { data: { createResource } }) => { - if (createResource == null) return; - if (!this.resource.actor) return; - const cachedData = store.readQuery<{ resource: IResource }>({ - query: GET_RESOURCE, - variables: { - path: this.resource.path, - username: this.resource.actor.preferredUsername, - }, - }); - if (cachedData == null) return; - const { resource } = cachedData; - if (resource == null) { - console.error("Cannot update resource cache, because of null value."); - return; - } - const newResource: IResource = createResource; - resource.children.elements = resource.children.elements.concat([newResource]); - - store.writeQuery({ - query: GET_RESOURCE, - variables: { - path: this.resource.path, - username: this.resource.actor.preferredUsername, - }, - data: { resource }, - }); - }, + refetchQueries: () => this.postRefreshQueries(), }); this.createLinkResourceModal = false; this.createResourceModal = false; @@ -429,6 +414,19 @@ export default class Resources extends Mixins(ResourceMixin) { }); } + // eslint-disable-next-line class-methods-use-this + private postRefreshQueries(): RefetchQueryDescription { + return [ + { + query: GET_RESOURCE, + variables: { + path: this.actualPath, + username: this.$route.params.preferredUsername, + }, + }, + ]; + } + async deleteResource(resourceID: string): Promise<void> { try { await this.$apollo.mutate({ @@ -436,37 +434,7 @@ export default class Resources extends Mixins(ResourceMixin) { variables: { id: resourceID, }, - update: (store, { data: { deleteResource } }) => { - if (deleteResource == null) return; - if (!this.resource.actor) return; - const cachedData = store.readQuery<{ resource: IResource }>({ - query: GET_RESOURCE, - variables: { - path: this.resource.path, - username: this.resource.actor.preferredUsername, - }, - }); - if (cachedData == null) return; - const { resource } = cachedData; - if (resource == null) { - console.error("Cannot update resource cache, because of null value."); - return; - } - const oldResource: IResource = deleteResource; - - resource.children.elements = resource.children.elements.filter( - (resourceElement) => resourceElement.id !== oldResource.id - ); - - store.writeQuery({ - query: GET_RESOURCE, - variables: { - path: this.resource.path, - username: this.resource.actor.preferredUsername, - }, - data: { resource }, - }); - }, + refetchQueries: () => this.postRefreshQueries(), }); this.validCheckedResources = this.validCheckedResources.filter((id) => id !== resourceID); delete this.checkedResources[resourceID]; @@ -476,6 +444,7 @@ export default class Resources extends Mixins(ResourceMixin) { } handleRename(resource: IResource): void { + console.log("handleRename"); this.renameModal = true; this.updatedResource = { ...resource }; } @@ -506,6 +475,7 @@ export default class Resources extends Mixins(ResourceMixin) { parentId: resource.parent ? resource.parent.id : null, path: resource.path, }, + refetchQueries: () => this.postRefreshQueries(), update: (store, { data }) => { if (!data || data.updateResource == null || parentPath == null) return; if (!this.resource.actor) return; @@ -577,9 +547,19 @@ export default class Resources extends Mixins(ResourceMixin) { console.error(e); } } + + handleErrors(errors: any[]): void { + if (errors.some((error) => error.status_code === 404)) { + this.$router.replace({ name: RouteName.PAGE_NOT_FOUND }); + } + } } </script> <style lang="scss" scoped> +.container.section { + background: $white; +} + nav.breadcrumb ul { align-items: center; diff --git a/lib/federation/activity_pub/activity_pub.ex b/lib/federation/activity_pub/activity_pub.ex index 5791a47c4..4332bdcb1 100644 --- a/lib/federation/activity_pub/activity_pub.ex +++ b/lib/federation/activity_pub/activity_pub.ex @@ -15,6 +15,7 @@ defmodule Mobilizon.Federation.ActivityPub do Config, Discussions, Events, + Posts, Resources, Share, Users @@ -88,6 +89,7 @@ defmodule Mobilizon.Federation.ActivityPub do {:existing, Discussions.get_discussion_by_url(url)}, {:existing, nil} <- {:existing, Discussions.get_comment_from_url(url)}, {:existing, nil} <- {:existing, Resources.get_resource_by_url(url)}, + {:existing, nil} <- {:existing, Posts.get_post_by_url(url)}, {:existing, nil} <- {:existing, Actors.get_actor_by_url_2(url)}, {:existing, nil} <- {:existing, Actors.get_member_by_url(url)}, @@ -109,6 +111,9 @@ defmodule Mobilizon.Federation.ActivityPub do {:error, "Gone"} -> {:error, "Gone", entity} + + {:error, "Not found"} -> + {:error, "Not found", entity} end else {:ok, entity} diff --git a/lib/federation/activity_pub/federator.ex b/lib/federation/activity_pub/federator.ex index a419834e8..74b947afe 100644 --- a/lib/federation/activity_pub/federator.ex +++ b/lib/federation/activity_pub/federator.ex @@ -50,7 +50,7 @@ defmodule Mobilizon.Federation.ActivityPub.Federator do def handle(:incoming_ap_doc, params) do Logger.info("Handling incoming AP activity") - Logger.debug(inspect(params)) + Logger.debug(inspect(Map.drop(params, ["@context"]))) case Transmogrifier.handle_incoming(params) do {:ok, activity, _data} -> diff --git a/lib/federation/activity_pub/fetcher.ex b/lib/federation/activity_pub/fetcher.ex index 97ff31fd9..a6ece6a36 100644 --- a/lib/federation/activity_pub/fetcher.ex +++ b/lib/federation/activity_pub/fetcher.ex @@ -32,6 +32,10 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do Logger.warn("Resource at #{url} is 410 Gone") {:error, "Gone"} + {:ok, %Tesla.Env{status: 404}} -> + Logger.warn("Resource at #{url} is 404 Gone") + {:error, "Not found"} + {:ok, %Tesla.Env{} = res} -> {:error, res} end diff --git a/lib/federation/activity_pub/preloader.ex b/lib/federation/activity_pub/preloader.ex index dedcb0971..79db7a65a 100644 --- a/lib/federation/activity_pub/preloader.ex +++ b/lib/federation/activity_pub/preloader.ex @@ -4,10 +4,11 @@ defmodule Mobilizon.Federation.ActivityPub.Preloader do """ # TODO: Move me in a more appropriate place - alias Mobilizon.{Actors, Discussions, Events, Resources} + alias Mobilizon.{Actors, Discussions, Events, Posts, Resources} alias Mobilizon.Actors.{Actor, Member} alias Mobilizon.Discussions.{Comment, Discussion} alias Mobilizon.Events.Event + alias Mobilizon.Posts.Post alias Mobilizon.Resources.Resource alias Mobilizon.Tombstone @@ -23,6 +24,9 @@ defmodule Mobilizon.Federation.ActivityPub.Preloader do def maybe_preload(%Resource{url: url}), do: {:ok, Resources.get_resource_by_url_with_preloads(url)} + def maybe_preload(%Post{url: url}), + do: {:ok, Posts.get_post_by_url_with_preloads(url)} + def maybe_preload(%Actor{url: url}), do: {:ok, Actors.get_actor_by_url!(url, true)} def maybe_preload(%Member{} = member), do: {:ok, member} diff --git a/lib/federation/activity_pub/refresher.ex b/lib/federation/activity_pub/refresher.ex index d825199da..2169e5212 100644 --- a/lib/federation/activity_pub/refresher.ex +++ b/lib/federation/activity_pub/refresher.ex @@ -118,7 +118,9 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do defp process_collection(_, _), do: :error - defp handling_element(data) when is_map(data) do + # If we're handling an activity + defp handling_element(%{"type" => activity_type} = data) + when activity_type in ["Create", "Update", "Delete"] do object = get_in(data, ["object"]) if object do @@ -128,6 +130,26 @@ defmodule Mobilizon.Federation.ActivityPub.Refresher do Transmogrifier.handle_incoming(data) end + # If we're handling directly an object + defp handling_element(data) when is_map(data) do + object = get_in(data, ["object"]) + + if object do + object |> Utils.get_url() |> Mobilizon.Tombstone.delete_uri_tombstone() + end + + activity = %{ + "type" => "Create", + "to" => data["to"], + "cc" => data["cc"], + "actor" => data["actor"] || data["attributedTo"], + "attributedTo" => data["attributedTo"] || data["actor"], + "object" => data + } + + Transmogrifier.handle_incoming(activity) + end + defp handling_element(uri) when is_binary(uri) do ActivityPub.fetch_object_from_url(uri) end diff --git a/lib/federation/activity_pub/transmogrifier.ex b/lib/federation/activity_pub/transmogrifier.ex index fb0830890..09e4559ae 100644 --- a/lib/federation/activity_pub/transmogrifier.ex +++ b/lib/federation/activity_pub/transmogrifier.ex @@ -381,12 +381,15 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do update_data ) do with actor <- Utils.get_actor(update_data), - {:ok, %Actor{url: actor_url, suspended: false}} <- + {:ok, %Actor{url: actor_url, suspended: false} = actor} <- ActivityPub.get_or_fetch_actor_by_url(actor), {:ok, %Event{} = old_event} <- object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(), object_data <- Converter.Event.as_to_model_data(object), - {:origin_check, true} <- {:origin_check, Utils.origin_check?(actor_url, update_data)}, + {:origin_check, true} <- + {:origin_check, + Utils.origin_check?(actor_url, update_data) || + Utils.can_update_group_object?(actor, old_event)}, {:ok, %Activity{} = activity, %Event{} = new_event} <- ActivityPub.update(old_event, object_data, false) do {:ok, activity, new_event} @@ -418,6 +421,57 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do end end + def handle_incoming( + %{"type" => "Update", "object" => %{"type" => "Article"} = object, "actor" => _actor} = + update_data + ) do + with actor <- Utils.get_actor(update_data), + {:ok, %Actor{url: actor_url, suspended: false} = actor} <- + ActivityPub.get_or_fetch_actor_by_url(actor), + {:ok, %Post{} = old_post} <- + object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(), + object_data <- Converter.Post.as_to_model_data(object), + {:origin_check, true} <- + {:origin_check, + Utils.origin_check?(actor_url, update_data["object"]) || + Utils.can_update_group_object?(actor, old_post)}, + {:ok, %Activity{} = activity, %Post{} = new_post} <- + ActivityPub.update(old_post, object_data, false) do + {:ok, activity, new_post} + else + {:origin_check, _} -> + Logger.warn("Actor tried to update a post but doesn't has the required role") + :error + + _e -> + :error + end + end + + def handle_incoming( + %{"type" => "Update", "object" => %{"type" => type} = object, "actor" => _actor} = + update_data + ) + when type in ["ResourceCollection", "Document"] do + with actor <- Utils.get_actor(update_data), + {:ok, %Actor{url: actor_url, suspended: false}} <- + ActivityPub.get_or_fetch_actor_by_url(actor), + {:ok, %Resource{} = old_resource} <- + object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(), + object_data <- Converter.Resource.as_to_model_data(object), + {:origin_check, true} <- + {:origin_check, + Utils.origin_check?(actor_url, update_data) || + Utils.can_update_group_object?(actor, old_resource)}, + {:ok, %Activity{} = activity, %Resource{} = new_resource} <- + ActivityPub.update(old_resource, object_data, false) do + {:ok, activity, new_resource} + else + _e -> + :error + end + end + def handle_incoming( %{"type" => "Update", "object" => %{"type" => "Member"} = object, "actor" => _actor} = update_data @@ -505,11 +559,11 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do with actor_url <- Utils.get_actor(data), {:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_actor_by_url(actor_url), object_id <- Utils.get_url(object), - {:error, "Gone", object} <- ActivityPub.fetch_object_from_url(object_id, force: true), + {:ok, object} <- is_group_object_gone(object_id), {:origin_check, true} <- {:origin_check, Utils.origin_check_from_id?(actor_url, object_id) || - Utils.activity_actor_is_group_member?(actor, object)}, + Utils.can_delete_group_object?(actor, object)}, {:ok, activity, object} <- ActivityPub.delete(object, actor, false) do {:ok, activity, object} else @@ -523,6 +577,29 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do end end + def handle_incoming( + %{"type" => "Move", "object" => %{"type" => type} = object, "actor" => _actor} = data + ) + when type in ["ResourceCollection", "Document"] do + with actor <- Utils.get_actor(data), + {:ok, %Actor{url: actor_url, suspended: false} = actor} <- + ActivityPub.get_or_fetch_actor_by_url(actor), + {:ok, %Resource{} = old_resource} <- + object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(), + object_data <- Converter.Resource.as_to_model_data(object), + {:origin_check, true} <- + {:origin_check, + Utils.origin_check?(actor_url, data) || + Utils.can_update_group_object?(actor, old_resource)}, + {:ok, activity, new_resource} <- ActivityPub.move(:resource, old_resource, object_data) do + {:ok, activity, new_resource} + else + e -> + Logger.error(inspect(e)) + :error + end + end + def handle_incoming( %{ "type" => "Join", @@ -975,4 +1052,25 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do fetch_object_optionnally_authenticated(url, actor) end end + + defp is_group_object_gone(object_id) do + case ActivityPub.fetch_object_from_url(object_id, force: true) do + {:error, error_message, object} when error_message in ["Gone", "Not found"] -> + {:ok, object} + + {:ok, %{url: url} = object} -> + if Utils.are_same_origin?(url, Endpoint.url()), + do: {:ok, object}, + else: {:error, "Group object URL remote"} + + {:error, {:error, err}} -> + {:error, err} + + {:error, err} -> + {:error, err} + + err -> + err + end + end end diff --git a/lib/federation/activity_pub/types/actors.ex b/lib/federation/activity_pub/types/actors.ex index 77dfc8871..bc147b9d8 100644 --- a/lib/federation/activity_pub/types/actors.ex +++ b/lib/federation/activity_pub/types/actors.ex @@ -88,6 +88,9 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do def group_actor(%Actor{} = actor), do: actor + def role_needed_to_update(%Actor{} = _group), do: :administrator + def role_needed_to_delete(%Actor{} = _group), do: :administrator + defp prepare_args_for_actor(args) do args |> maybe_sanitize_username() diff --git a/lib/federation/activity_pub/types/comments.ex b/lib/federation/activity_pub/types/comments.ex index 1c1f04f6d..019a2958a 100644 --- a/lib/federation/activity_pub/types/comments.ex +++ b/lib/federation/activity_pub/types/comments.ex @@ -98,6 +98,9 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Comments do def group_actor(_), do: nil + def role_needed_to_update(%Comment{attributed_to: %Actor{} = _group}), do: :administrator + def role_needed_to_delete(%Comment{attributed_to_id: _attributed_to_id}), do: :administrator + # Prepare and sanitize arguments for comments defp prepare_args_for_comment(args) do with in_reply_to_comment <- diff --git a/lib/federation/activity_pub/types/discussions.ex b/lib/federation/activity_pub/types/discussions.ex index 0d5248f0e..57eb77033 100644 --- a/lib/federation/activity_pub/types/discussions.ex +++ b/lib/federation/activity_pub/types/discussions.ex @@ -89,6 +89,9 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Discussions do def group_actor(%Discussion{actor_id: actor_id}), do: Actors.get_actor(actor_id) + def role_needed_to_update(%Discussion{}), do: :moderator + def role_needed_to_delete(%Discussion{}), do: :moderator + @spec maybe_publish_graphql_subscription(Discussion.t()) :: :ok defp maybe_publish_graphql_subscription(%Discussion{} = discussion) do Absinthe.Subscription.publish(Endpoint, discussion, diff --git a/lib/federation/activity_pub/types/entity.ex b/lib/federation/activity_pub/types/entity.ex index ffb3974c4..5aa3253aa 100644 --- a/lib/federation/activity_pub/types/entity.ex +++ b/lib/federation/activity_pub/types/entity.ex @@ -57,6 +57,8 @@ defprotocol Mobilizon.Federation.ActivityPub.Types.Managable do end defprotocol Mobilizon.Federation.ActivityPub.Types.Ownable do + @type group_role :: :member | :moderator | :administrator | nil + @spec group_actor(Entity.t()) :: Actor.t() | nil @doc "Returns an eventual group for the entity" def group_actor(entity) @@ -64,6 +66,12 @@ defprotocol Mobilizon.Federation.ActivityPub.Types.Ownable do @spec actor(Entity.t()) :: Actor.t() | nil @doc "Returns the actor for the entity" def actor(entity) + + @spec role_needed_to_update(Entity.t()) :: group_role() + def role_needed_to_update(entity) + + @spec role_needed_to_delete(Entity.t()) :: group_role() + def role_needed_to_delete(entity) end defimpl Managable, for: Event do @@ -74,6 +82,8 @@ end defimpl Ownable, for: Event do defdelegate group_actor(entity), to: Events defdelegate actor(entity), to: Events + defdelegate role_needed_to_update(entity), to: Events + defdelegate role_needed_to_delete(entity), to: Events end defimpl Managable, for: Comment do @@ -84,6 +94,8 @@ end defimpl Ownable, for: Comment do defdelegate group_actor(entity), to: Comments defdelegate actor(entity), to: Comments + defdelegate role_needed_to_update(entity), to: Comments + defdelegate role_needed_to_delete(entity), to: Comments end defimpl Managable, for: Post do @@ -94,6 +106,8 @@ end defimpl Ownable, for: Post do defdelegate group_actor(entity), to: Posts defdelegate actor(entity), to: Posts + defdelegate role_needed_to_update(entity), to: Posts + defdelegate role_needed_to_delete(entity), to: Posts end defimpl Managable, for: Actor do @@ -104,6 +118,8 @@ end defimpl Ownable, for: Actor do defdelegate group_actor(entity), to: Actors defdelegate actor(entity), to: Actors + defdelegate role_needed_to_update(entity), to: Actors + defdelegate role_needed_to_delete(entity), to: Actors end defimpl Managable, for: TodoList do @@ -114,6 +130,8 @@ end defimpl Ownable, for: TodoList do defdelegate group_actor(entity), to: TodoLists defdelegate actor(entity), to: TodoLists + defdelegate role_needed_to_update(entity), to: TodoLists + defdelegate role_needed_to_delete(entity), to: TodoLists end defimpl Managable, for: Todo do @@ -124,6 +142,8 @@ end defimpl Ownable, for: Todo do defdelegate group_actor(entity), to: Todos defdelegate actor(entity), to: Todos + defdelegate role_needed_to_update(entity), to: Todos + defdelegate role_needed_to_delete(entity), to: Todos end defimpl Managable, for: Resource do @@ -134,6 +154,8 @@ end defimpl Ownable, for: Resource do defdelegate group_actor(entity), to: Resources defdelegate actor(entity), to: Resources + defdelegate role_needed_to_update(entity), to: Resources + defdelegate role_needed_to_delete(entity), to: Resources end defimpl Managable, for: Discussion do @@ -144,11 +166,15 @@ end defimpl Ownable, for: Discussion do defdelegate group_actor(entity), to: Discussions defdelegate actor(entity), to: Discussions + defdelegate role_needed_to_update(entity), to: Discussions + defdelegate role_needed_to_delete(entity), to: Discussions end defimpl Ownable, for: Tombstone do defdelegate group_actor(entity), to: Tombstones defdelegate actor(entity), to: Tombstones + defdelegate role_needed_to_update(entity), to: Tombstones + defdelegate role_needed_to_delete(entity), to: Tombstones end defimpl Managable, for: Member do diff --git a/lib/federation/activity_pub/types/events.ex b/lib/federation/activity_pub/types/events.ex index febfd2a25..2cd0a9d5e 100644 --- a/lib/federation/activity_pub/types/events.ex +++ b/lib/federation/activity_pub/types/events.ex @@ -88,6 +88,10 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Events do def group_actor(_), do: nil + def role_needed_to_update(%Event{attributed_to: %Actor{} = _group}), do: :moderator + def role_needed_to_delete(%Event{attributed_to_id: _attributed_to_id}), do: :moderator + def role_needed_to_delete(_), do: nil + def join(%Event{} = event, %Actor{} = actor, _local, additional) do with {:maximum_attendee_capacity, true} <- {:maximum_attendee_capacity, check_attendee_capacity(event)}, diff --git a/lib/federation/activity_pub/types/posts.ex b/lib/federation/activity_pub/types/posts.ex index f7cd1104a..bddfaf475 100644 --- a/lib/federation/activity_pub/types/posts.ex +++ b/lib/federation/activity_pub/types/posts.ex @@ -1,6 +1,6 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Posts do @moduledoc false - alias Mobilizon.{Actors, Posts} + alias Mobilizon.{Actors, Posts, Tombstone} alias Mobilizon.Actors.Actor alias Mobilizon.Federation.ActivityPub.Types.Entity alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils @@ -11,6 +11,8 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Posts do @behaviour Entity + @public_ap "https://www.w3.org/ns/activitystreams#Public" + @impl Entity def create(args, additional) do with args <- Map.update(args, :tags, [], &ConverterUtils.fetch_tags/1), @@ -42,7 +44,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Posts do {:ok, %Post{attributed_to_id: group_id, author_id: creator_id} = post} <- Posts.update_post(post, args), {:ok, true} <- Cachex.del(:activity_pub, "post_#{post.slug}"), - {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id), + {:ok, %Actor{url: group_url} = group} <- Actors.get_group_by_actor_id(group_id), %Actor{url: creator_url} = creator <- Actors.get_actor(creator_id), post_as_data <- Convertible.model_to_as(%{post | attributed_to: group, author: creator}), @@ -50,7 +52,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Posts do "to" => [group.members_url], "cc" => [], "actor" => creator_url, - "attributedTo" => [creator_url] + "attributedTo" => [group_url] } do update_data = make_update_data(post_as_data, Map.merge(audience, additional)) @@ -66,7 +68,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Posts do def delete( %Post{ url: url, - attributed_to: %Actor{url: group_url} + attributed_to: %Actor{url: group_url, members_url: members_url} } = post, %Actor{url: actor_url} = actor, _local, @@ -77,11 +79,13 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Posts do "type" => "Delete", "object" => Convertible.model_to_as(post), "id" => url <> "/delete", - "to" => [group_url] + "to" => [group_url, @public_ap, members_url] } - with {:ok, _post} <- Posts.delete_post(post), - {:ok, true} <- Cachex.del(:activity_pub, "post_#{post.slug}") do + with {:ok, %Post{} = post} <- Posts.delete_post(post), + {:ok, true} <- Cachex.del(:activity_pub, "post_#{post.slug}"), + {:ok, %Tombstone{} = _tombstone} <- + Tombstone.create_tombstone(%{uri: post.url, actor_id: actor.id}) do {:ok, activity_data, actor, post} end end @@ -91,4 +95,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Posts do def group_actor(%Post{attributed_to_id: attributed_to_id}), do: Actors.get_actor(attributed_to_id) + + def role_needed_to_update(%Post{}), do: :moderator + def role_needed_to_delete(%Post{}), do: :moderator end diff --git a/lib/federation/activity_pub/types/resources.ex b/lib/federation/activity_pub/types/resources.ex index 025b8af21..c57afcb86 100644 --- a/lib/federation/activity_pub/types/resources.ex +++ b/lib/federation/activity_pub/types/resources.ex @@ -155,4 +155,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Resources do do: Actors.get_actor(creator_id) def group_actor(%Resource{actor_id: actor_id}), do: Actors.get_actor(actor_id) + + def role_needed_to_update(%Resource{}), do: :member + def role_needed_to_delete(%Resource{}), do: :member end diff --git a/lib/federation/activity_pub/types/todo_lists.ex b/lib/federation/activity_pub/types/todo_lists.ex index b2b170f16..1b9bc590b 100644 --- a/lib/federation/activity_pub/types/todo_lists.ex +++ b/lib/federation/activity_pub/types/todo_lists.ex @@ -67,4 +67,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.TodoLists do def actor(%TodoList{}), do: nil def group_actor(%TodoList{actor_id: actor_id}), do: Actors.get_actor(actor_id) + + def role_needed_to_update(%TodoList{}), do: :member + def role_needed_to_delete(%TodoList{}), do: :member end diff --git a/lib/federation/activity_pub/types/todos.ex b/lib/federation/activity_pub/types/todos.ex index 50e573eb9..dab23ca95 100644 --- a/lib/federation/activity_pub/types/todos.ex +++ b/lib/federation/activity_pub/types/todos.ex @@ -79,4 +79,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Todos do nil end end + + def role_needed_to_update(%Todo{}), do: :member + def role_needed_to_delete(%Todo{}), do: :member end diff --git a/lib/federation/activity_pub/types/tombstones.ex b/lib/federation/activity_pub/types/tombstones.ex index b3f36cb6e..0787d47be 100644 --- a/lib/federation/activity_pub/types/tombstones.ex +++ b/lib/federation/activity_pub/types/tombstones.ex @@ -11,4 +11,7 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Tombstones do def actor(_), do: nil def group_actor(_), do: nil + + def role_needed_to_update(%Actor{}), do: nil + def role_needed_to_delete(%Actor{}), do: nil end diff --git a/lib/federation/activity_pub/utils.ex b/lib/federation/activity_pub/utils.ex index ee763bb32..6f9a64413 100644 --- a/lib/federation/activity_pub/utils.ex +++ b/lib/federation/activity_pub/utils.ex @@ -287,10 +287,41 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do def origin_check_from_id?(id, %{"id" => other_id} = _params) when is_binary(other_id), do: origin_check_from_id?(id, other_id) - def activity_actor_is_group_member?(%Actor{id: actor_id}, object) do + def activity_actor_is_group_member?( + %Actor{id: actor_id, url: actor_url}, + object, + role \\ :member + ) do case Ownable.group_actor(object) do - %Actor{type: :Group, id: group_id} -> - Actors.is_member?(actor_id, group_id) + %Actor{type: :Group, id: group_id, url: group_url} -> + Logger.debug("Group object url is #{group_url}") + + case role do + :moderator -> + Logger.debug( + "Checking if activity actor #{actor_url} is a moderator from group from #{ + object.url + }" + ) + + Actors.is_moderator?(actor_id, group_id) + + :administrator -> + Logger.debug( + "Checking if activity actor #{actor_url} is an administrator from group from #{ + object.url + }" + ) + + Actors.is_administrator?(actor_id, group_id) + + _ -> + Logger.debug( + "Checking if activity actor #{actor_url} is a member from group from #{object.url}" + ) + + Actors.is_member?(actor_id, group_id) + end _ -> false @@ -628,4 +659,39 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do :ok end + + def can_update_group_object?(%Actor{} = actor, object) do + can_manage_group_object?(:role_needed_to_update, actor, object) + end + + def can_delete_group_object?(%Actor{} = actor, object) do + can_manage_group_object?(:role_needed_to_delete, actor, object) + end + + @spec can_manage_group_object?( + :role_needed_to_update | :role_needed_to_delete, + Actor.t(), + any() + ) :: boolean() + defp can_manage_group_object?(action_function, %Actor{url: actor_url} = actor, object) do + if Ownable.group_actor(object) != nil do + case apply(Ownable, action_function, [object]) do + role when role in [:member, :moderator, :administrator] -> + activity_actor_is_group_member?(actor, object, role) + + _ -> + case action_function do + :role_needed_to_update -> + Logger.warn("Actor #{actor_url} can't update #{object.url}") + + :role_needed_to_delete -> + Logger.warn("Actor #{actor_url} can't delete #{object.url}") + end + + false + end + else + true + end + end end diff --git a/lib/graphql/error.ex b/lib/graphql/error.ex index 2e20a228b..6d5def169 100644 --- a/lib/graphql/error.ex +++ b/lib/graphql/error.ex @@ -88,6 +88,7 @@ defmodule Mobilizon.GraphQL.Error do defp metadata(:post_not_found), do: {404, dgettext("errors", "Post not found")} defp metadata(:event_not_found), do: {404, dgettext("errors", "Event not found")} defp metadata(:group_not_found), do: {404, dgettext("errors", "Group not found")} + defp metadata(:resource_not_found), do: {404, dgettext("errors", "Resource not found")} defp metadata(:unknown), do: {500, dgettext("errors", "Something went wrong")} defp metadata(code) do diff --git a/lib/graphql/resolvers/post.ex b/lib/graphql/resolvers/post.ex index 5f98a72e0..abd9666fc 100644 --- a/lib/graphql/resolvers/post.ex +++ b/lib/graphql/resolvers/post.ex @@ -149,7 +149,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do } = _resolution ) do with {:uuid, {:ok, _uuid}} <- {:uuid, Ecto.UUID.cast(id)}, - %Actor{id: actor_id} <- Users.get_actor_for_user(user), + %Actor{id: actor_id, url: actor_url} <- Users.get_actor_for_user(user), {:post, %Post{attributed_to: %Actor{id: group_id} = group} = post} <- {:post, Posts.get_post_with_preloads(id)}, args <- @@ -158,7 +158,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do end), {:member, true} <- {:member, Actors.is_member?(actor_id, group_id)}, {:ok, _, %Post{} = post} <- - ActivityPub.update(post, args, true, %{}) do + ActivityPub.update(post, args, true, %{"actor" => actor_url}) do {:ok, post} else {:uuid, :error} -> diff --git a/lib/graphql/resolvers/resource.ex b/lib/graphql/resolvers/resource.ex index 29011a526..d68439f58 100644 --- a/lib/graphql/resolvers/resource.ex +++ b/lib/graphql/resolvers/resource.ex @@ -83,8 +83,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Resource do {:resource, Resources.get_resource_by_group_and_path_with_preloads(group_id, path)} do {:ok, resource} else + {:group, _} -> {:error, :group_not_found} {:member, false} -> {:error, dgettext("errors", "Profile is not member of group")} - {:resource, _} -> {:error, dgettext("errors", "No such resource")} + {:resource, _} -> {:error, :resource_not_found} end end @@ -137,12 +138,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Resource do } } = _resolution ) do - with %Actor{id: actor_id} <- Users.get_actor_for_user(user), + with %Actor{id: actor_id, url: actor_url} <- Users.get_actor_for_user(user), {:resource, %Resource{actor_id: group_id} = resource} <- {:resource, Resources.get_resource_with_preloads(resource_id)}, {:member, true} <- {:member, Actors.is_member?(actor_id, group_id)}, {:ok, _, %Resource{} = resource} <- - ActivityPub.update(resource, args, true, %{}) do + ActivityPub.update(resource, args, true, %{"actor" => actor_url}) do {:ok, resource} else {:resource, _} -> @@ -195,8 +196,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Resource do } } = _resolution ) do - with {:ok, data} when is_map(data) <- Parser.parse(resource_url) do - {:ok, struct(Metadata, data)} + case Parser.parse(resource_url) do + {:ok, data} when is_map(data) -> + {:ok, struct(Metadata, data)} + + {:error, _err} -> + Logger.warn("Error while fetching preview from #{inspect(resource_url)}") + {:error, :unknown_resource} end end diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex index 92d40e9b5..7270c6d61 100644 --- a/lib/mobilizon/events/events.ex +++ b/lib/mobilizon/events/events.ex @@ -406,7 +406,7 @@ defmodule Mobilizon.Events do def list_public_events_for_actor(actor, page \\ nil, limit \\ nil) def list_public_events_for_actor(%Actor{type: :Group} = group, page, limit), - do: list_organized_events_for_group(group, :public, nil, page, limit) + do: list_organized_events_for_group(group, :public, nil, nil, page, limit) def list_public_events_for_actor(%Actor{id: actor_id}, page, limit) do actor_id diff --git a/lib/service/http/rich_media_preview_client.ex b/lib/service/http/rich_media_preview_client.ex new file mode 100644 index 000000000..edd190818 --- /dev/null +++ b/lib/service/http/rich_media_preview_client.ex @@ -0,0 +1,23 @@ +defmodule Mobilizon.Service.HTTP.RichMediaPreviewClient do + @moduledoc """ + Tesla HTTP Basic Client + with JSON middleware + """ + + use Tesla + alias Mobilizon.Config + + @default_opts [ + recv_timeout: 20_000 + ] + + adapter(Tesla.Adapter.Hackney, @default_opts) + + @user_agent Config.instance_user_agent() + + plug(Tesla.Middleware.FollowRedirects) + + plug(Tesla.Middleware.Timeout, timeout: 10_000) + + plug(Tesla.Middleware.Headers, [{"User-Agent", @user_agent}]) +end diff --git a/lib/service/rich_media/parser.ex b/lib/service/rich_media/parser.ex index fa16a328c..12face84b 100644 --- a/lib/service/rich_media/parser.ex +++ b/lib/service/rich_media/parser.ex @@ -17,6 +17,7 @@ defmodule Mobilizon.Service.RichMedia.Parser do ] alias Mobilizon.Config + alias Mobilizon.Service.HTTP.RichMediaPreviewClient alias Mobilizon.Service.RichMedia.Favicon alias Plug.Conn.Utils require Logger @@ -56,7 +57,7 @@ defmodule Mobilizon.Service.RichMedia.Parser do with {:ok, _} <- prevent_local_address(url), {:ok, %{body: body, status: code, headers: response_headers}} when code in 200..299 <- - Tesla.get( + RichMediaPreviewClient.get( url, headers: headers, opts: @options @@ -188,8 +189,11 @@ defmodule Mobilizon.Service.RichMedia.Parser do defp maybe_parse(html) do Enum.reduce_while(parsers(), %{}, fn parser, acc -> case parser.parse(html, acc) do - {:ok, data} -> {:halt, data} - {:error, _msg} -> {:cont, acc} + {:ok, data} -> + {:halt, data} + + {:error, _msg} -> + {:cont, acc} end end) end @@ -237,7 +241,7 @@ defmodule Mobilizon.Service.RichMedia.Parser do !String.ends_with?(hostname, ".localhost") defp validate_hostname_only(hostname), - do: hostname |> String.graphemes() |> Enum.count(&(&1 == "o")) > 0 + do: hostname |> String.graphemes() |> Enum.count(&(&1 == ".")) > 0 defp validate_ip(hostname) do case hostname |> String.to_charlist() |> :inet.parse_address() do diff --git a/lib/service/rich_media/parsers/oembed_parser.ex b/lib/service/rich_media/parsers/oembed_parser.ex index e1e3c19f8..dcc2d3e0b 100644 --- a/lib/service/rich_media/parsers/oembed_parser.ex +++ b/lib/service/rich_media/parsers/oembed_parser.ex @@ -21,7 +21,7 @@ defmodule Mobilizon.Service.RichMedia.Parsers.OEmbed do with elements = [_ | _] <- get_discovery_data(html), {:ok, oembed_url} <- get_oembed_url(elements), {:ok, oembed_data} <- get_oembed_data(oembed_url), - oembed_data <- filter_oembed_data(oembed_data) do + {:ok, oembed_data} <- filter_oembed_data(oembed_data) do Logger.debug("Data found with OEmbed parser") Logger.debug(inspect(oembed_data)) {:ok, oembed_data} @@ -55,7 +55,9 @@ defmodule Mobilizon.Service.RichMedia.Parsers.OEmbed do {:error, "No type declared for OEmbed data"} "link" -> - Map.put(data, :image_remote_url, Map.get(data, :thumbnail_url)) + data + |> Map.put(:image_remote_url, Map.get(data, :thumbnail_url)) + |> (&{:ok, &1}).() "photo" -> if Map.get(data, :url, "") == "" do @@ -65,6 +67,7 @@ defmodule Mobilizon.Service.RichMedia.Parsers.OEmbed do |> Map.put(:image_remote_url, Map.get(data, :url)) |> Map.put(:width, Map.get(data, :width, 0)) |> Map.put(:height, Map.get(data, :height, 0)) + |> (&{:ok, &1}).() end "video" -> @@ -75,6 +78,7 @@ defmodule Mobilizon.Service.RichMedia.Parsers.OEmbed do |> Map.put(:width, Map.get(data, :width, 0)) |> Map.put(:height, Map.get(data, :height, 0)) |> Map.put(:image_remote_url, Map.get(data, :thumbnail_url)) + |> (&{:ok, &1}).() "rich" -> {:error, "OEmbed data has rich type, which we don't support"} diff --git a/test/federation/activity_pub/transmogrifier/delete_test.exs b/test/federation/activity_pub/transmogrifier/delete_test.exs new file mode 100644 index 000000000..30717266a --- /dev/null +++ b/test/federation/activity_pub/transmogrifier/delete_test.exs @@ -0,0 +1,317 @@ +defmodule Mobilizon.Federation.ActivityPub.Transmogrifier.DeleteTest do + use Mobilizon.DataCase + use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + use Oban.Testing, repo: Mobilizon.Storage.Repo + import Mobilizon.Factory + import ExUnit.CaptureLog + import Mox + + alias Mobilizon.{Actors, Discussions, Events, Posts, Resources} + alias Mobilizon.Actors.Actor + alias Mobilizon.Discussions.Comment + alias Mobilizon.Events.Event + alias Mobilizon.Posts.Post + alias Mobilizon.Resources.Resource + alias Mobilizon.Federation.ActivityPub.{Activity, Transmogrifier} + alias Mobilizon.Federation.ActivityStream.Convertible + alias Mobilizon.Service.HTTP.ActivityPub.Mock + + describe "handle incoming delete activities" do + test "it works for incoming deletes" do + %Actor{url: actor_url} = + actor = insert(:actor, url: "http://mobilizon.tld/@remote", domain: "mobilizon.tld") + + %Comment{url: comment_url} = + insert(:comment, + actor: nil, + actor_id: actor.id, + url: "http://mobilizon.tld/comments/9f3794b8-11a0-4a98-8cb7-475ab332c701" + ) + + Mock + |> expect(:call, fn + %{method: :get, url: "http://mobilizon.tld/comments/9f3794b8-11a0-4a98-8cb7-475ab332c701"}, + _opts -> + {:ok, %Tesla.Env{status: 410, body: "Gone"}} + end) + + data = + File.read!("test/fixtures/mastodon-delete.json") + |> Jason.decode!() + + object = + data["object"] + |> Map.put("id", comment_url) + + data = + data + |> Map.put("object", object) + |> Map.put("actor", actor_url) + + assert Discussions.get_comment_from_url(comment_url) + assert is_nil(Discussions.get_comment_from_url(comment_url).deleted_at) + + {:ok, %Activity{local: false}, _} = Transmogrifier.handle_incoming(data) + + refute is_nil(Discussions.get_comment_from_url(comment_url).deleted_at) + end + + test "it fails for incoming deletes with spoofed origin" do + comment = insert(:comment) + + announce_data = + File.read!("test/fixtures/mastodon-announce.json") + |> Jason.decode!() + |> Map.put("object", comment.url) + + {:ok, _, _} = Transmogrifier.handle_incoming(announce_data) + + data = + File.read!("test/fixtures/mastodon-delete.json") + |> Jason.decode!() + + object = + data["object"] + |> Map.put("id", comment.url) + + data = + data + |> Map.put("object", object) + + :error = Transmogrifier.handle_incoming(data) + + assert Discussions.get_comment_from_url(comment.url) + end + + setup :set_mox_from_context + + test "it works for incoming actor deletes" do + %Actor{url: url} = + actor = insert(:actor, url: "https://framapiaf.org/users/admin", domain: "framapiaf.org") + + %Event{url: event1_url} = event1 = insert(:event, organizer_actor: actor) + insert(:event, organizer_actor: actor) + + %Comment{url: comment1_url} = comment1 = insert(:comment, actor: actor) + insert(:comment, actor: actor) + + data = + File.read!("test/fixtures/mastodon-delete-user.json") + |> Jason.decode!() + + Mock + |> expect(:call, fn + %{method: :get, url: "https://framapiaf.org/users/admin"}, _opts -> + {:ok, %Tesla.Env{status: 410, body: "Gone"}} + end) + + {:ok, _activity, _actor} = Transmogrifier.handle_incoming(data) + assert %{success: 1, failure: 0} == Oban.drain_queue(queue: :background) + + assert {:error, :actor_not_found} = Actors.get_actor_by_url(url) + assert {:error, :event_not_found} = Events.get_event(event1.id) + # Tombstone are cascade deleted, seems correct for now + # assert %Tombstone{} = Tombstone.find_tombstone(event1_url) + assert %Comment{deleted_at: deleted_at} = Discussions.get_comment(comment1.id) + refute is_nil(deleted_at) + # assert %Tombstone{} = Tombstone.find_tombstone(comment1_url) + end + + test "it fails for incoming actor deletes with spoofed origin" do + %{url: url} = insert(:actor) + deleted_actor_url = "https://framapiaf.org/users/admin" + + data = + File.read!("test/fixtures/mastodon-delete-user.json") + |> Jason.decode!() + |> Map.put("actor", url) + + deleted_actor_data = + File.read!("test/fixtures/mastodon-actor.json") + |> Jason.decode!() + |> Map.put("id", deleted_actor_url) + + Mock + |> expect(:call, fn + %{url: ^deleted_actor_url}, _opts -> + {:ok, %Tesla.Env{status: 200, body: deleted_actor_data}} + end) + + assert capture_log(fn -> + assert :error == Transmogrifier.handle_incoming(data) + end) =~ "Object origin check failed" + + assert Actors.get_actor_by_url(url) + end + end + + describe "handle incoming delete activities for group posts" do + test "works for remote deletions by moderators" do + %Actor{url: remote_actor_url} = + remote_actor = + insert(:actor, + domain: "remote.domain", + url: "https://remote.domain/@remote", + preferred_username: "remote" + ) + + group = insert(:group) + insert(:member, actor: remote_actor, parent: group, role: :moderator) + %Post{} = post = insert(:post, attributed_to: group) + + data = Convertible.model_to_as(post) + refute is_nil(Posts.get_post_by_url(data["id"])) + + delete_data = + File.read!("test/fixtures/mastodon-delete.json") + |> Jason.decode!() + + object = + data + |> Map.put("type", "Article") + + delete_data = + delete_data + |> Map.put("actor", remote_actor_url) + |> Map.put("object", object) + + {:ok, _activity, _actor} = Transmogrifier.handle_incoming(delete_data) + + assert is_nil(Posts.get_post_by_url(data["id"])) + end + + test "doesn't work for remote deletions if the actor is just a group member" do + %Actor{url: remote_actor_url} = + remote_actor = + insert(:actor, + domain: "remote.domain", + url: "https://remote.domain/@remote", + preferred_username: "remote" + ) + + group = insert(:group) + insert(:member, actor: remote_actor, parent: group, role: :member) + %Post{} = post = insert(:post, attributed_to: group) + + data = Convertible.model_to_as(post) + refute is_nil(Posts.get_post_by_url(data["id"])) + + delete_data = + File.read!("test/fixtures/mastodon-delete.json") + |> Jason.decode!() + + object = + data + |> Map.put("type", "Article") + + delete_data = + delete_data + |> Map.put("actor", remote_actor_url) + |> Map.put("object", object) + + :error = Transmogrifier.handle_incoming(delete_data) + + refute is_nil(Posts.get_post_by_url(data["id"])) + end + + test "doesn't work for remote deletions if the actor is not a group member" do + %Actor{url: remote_actor_url} = + insert(:actor, + domain: "remote.domain", + url: "https://remote.domain/@remote", + preferred_username: "remote" + ) + + group = insert(:group) + %Post{} = post = insert(:post, attributed_to: group) + + data = Convertible.model_to_as(post) + refute is_nil(Posts.get_post_by_url(data["id"])) + + delete_data = + File.read!("test/fixtures/mastodon-delete.json") + |> Jason.decode!() + + object = + data + |> Map.put("type", "Article") + + delete_data = + delete_data + |> Map.put("actor", remote_actor_url) + |> Map.put("object", object) + + :error = Transmogrifier.handle_incoming(delete_data) + + refute is_nil(Posts.get_post_by_url(data["id"])) + end + end + + describe "handle incoming delete activities for resources" do + test "works for remote deletions" do + %Actor{url: remote_actor_url} = + remote_actor = + insert(:actor, + domain: "remote.domain", + url: "http://remote.domain/@remote", + preferred_username: "remote" + ) + + group = insert(:group) + insert(:member, actor: remote_actor, parent: group, role: :member) + %Resource{} = resource = insert(:resource, actor: group) + + data = Convertible.model_to_as(resource) + refute is_nil(Resources.get_resource_by_url(data["id"])) + + delete_data = + File.read!("test/fixtures/mastodon-delete.json") + |> Jason.decode!() + + object = + data + |> Map.put("type", "Document") + + delete_data = + delete_data + |> Map.put("actor", remote_actor_url) + |> Map.put("object", object) + + {:ok, _activity, _actor} = Transmogrifier.handle_incoming(delete_data) + + assert is_nil(Resources.get_resource_by_url(data["id"])) + end + + test "doesn't work for remote deletions if the actor is not a group member" do + %Actor{url: remote_actor_url} = + insert(:actor, + domain: "remote.domain", + url: "http://remote.domain/@remote", + preferred_username: "remote" + ) + + group = insert(:group) + %Post{} = post = insert(:post, attributed_to: group) + + data = Convertible.model_to_as(post) + refute is_nil(Posts.get_post_by_url(data["id"])) + + delete_data = + File.read!("test/fixtures/mastodon-delete.json") + |> Jason.decode!() + + object = + data + |> Map.put("type", "Article") + + delete_data = + delete_data + |> Map.put("actor", remote_actor_url) + |> Map.put("object", object) + + :error = Transmogrifier.handle_incoming(delete_data) + + refute is_nil(Posts.get_post_by_url(data["id"])) + end + end +end diff --git a/test/federation/activity_pub/transmogrifier/update_test.exs b/test/federation/activity_pub/transmogrifier/update_test.exs new file mode 100644 index 000000000..be9786855 --- /dev/null +++ b/test/federation/activity_pub/transmogrifier/update_test.exs @@ -0,0 +1,227 @@ +defmodule Mobilizon.Federation.ActivityPub.Transmogrifier.UpdateTest do + use Mobilizon.DataCase + use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney + use Oban.Testing, repo: Mobilizon.Storage.Repo + import Mobilizon.Factory + + alias Mobilizon.{Actors, Events, Posts} + alias Mobilizon.Actors.{Actor, Member} + alias Mobilizon.Events.Event + alias Mobilizon.Posts.Post + alias Mobilizon.Federation.ActivityPub.{Activity, Transmogrifier} + alias Mobilizon.Federation.ActivityStream.Convertible + + describe "handle incoming update activities" do + test "it works for incoming update activities on actors" do + use_cassette "activity_pub/update_actor_activity" do + data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() + + {:ok, %Activity{data: data, local: false}, _} = Transmogrifier.handle_incoming(data) + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() + + object = + update_data["object"] + |> Map.put("actor", data["actor"]) + |> Map.put("id", data["actor"]) + + update_data = + update_data + |> Map.put("actor", data["actor"]) + |> Map.put("object", object) + + {:ok, %Activity{data: _data, local: false}, _} = + Transmogrifier.handle_incoming(update_data) + + {:ok, %Actor{} = actor} = Actors.get_actor_by_url(update_data["actor"]) + assert actor.name == "nextsoft" + + assert actor.summary == "<p>Some bio</p>" + end + end + + test "it works for incoming update activities on events" do + use_cassette "activity_pub/event_update_activities" do + data = File.read!("test/fixtures/mobilizon-post-activity.json") |> Jason.decode!() + + {:ok, %Activity{data: data, local: false}, %Event{id: event_id}} = + Transmogrifier.handle_incoming(data) + + assert_enqueued( + worker: Mobilizon.Service.Workers.BuildSearch, + args: %{event_id: event_id, op: :insert_search_event} + ) + + assert %{success: 1, failure: 0} == Oban.drain_queue(queue: :search) + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() + + object = + data["object"] + |> Map.put("actor", data["actor"]) + |> Map.put("name", "My updated event") + |> Map.put("id", data["object"]["id"]) + |> Map.put("type", "Event") + + update_data = + update_data + |> Map.put("actor", data["actor"]) + |> Map.put("object", object) + + {:ok, %Activity{data: data, local: false}, _} = + Transmogrifier.handle_incoming(update_data) + + %Event{} = event = Events.get_event_by_url(data["object"]["id"]) + + assert_enqueued( + worker: Mobilizon.Service.Workers.BuildSearch, + args: %{event_id: event_id, op: :update_search_event} + ) + + assert %{success: 1, failure: 0} == Oban.drain_queue(queue: :search) + + assert event.title == "My updated event" + + assert event.description == data["object"]["content"] + end + end + + # test "it works for incoming update activities which lock the account" do + # data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() + + # {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) + # update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() + + # object = + # update_data["object"] + # |> Map.put("actor", data["actor"]) + # |> Map.put("id", data["actor"]) + # |> Map.put("manuallyApprovesFollowers", true) + + # update_data = + # update_data + # |> Map.put("actor", data["actor"]) + # |> Map.put("object", object) + + # {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(update_data) + + # user = User.get_cached_by_ap_id(data["actor"]) + # assert user.info["locked"] == true + # end + end + + describe "handle incoming updates activities for group posts" do + test "it works for incoming update activities on group posts when remote actor is a moderator" do + use_cassette "activity_pub/group_post_update_activities" do + %Actor{url: remote_actor_url} = + remote_actor = + insert(:actor, + domain: "remote.domain", + url: "https://remote.domain/@remote", + preferred_username: "remote" + ) + + group = insert(:group) + %Member{} = member = insert(:member, actor: remote_actor, parent: group, role: :moderator) + %Post{} = post = insert(:post, attributed_to: group) + + data = Convertible.model_to_as(post) + refute is_nil(Posts.get_post_by_url(data["id"])) + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() + + object = + data + |> Map.put("name", "My updated post") + |> Map.put("type", "Article") + + update_data = + update_data + |> Map.put("actor", remote_actor_url) + |> Map.put("object", object) + + {:ok, %Activity{data: data, local: false}, _} = + Transmogrifier.handle_incoming(update_data) + + %Post{id: updated_post_id, title: updated_post_title} = + Posts.get_post_by_url(data["object"]["id"]) + + assert updated_post_id == post.id + assert updated_post_title == "My updated post" + end + end + + test "it works for incoming update activities on group posts" do + use_cassette "activity_pub/group_post_update_activities" do + %Actor{url: remote_actor_url} = + remote_actor = + insert(:actor, + domain: "remote.domain", + url: "https://remote.domain/@remote", + preferred_username: "remote" + ) + + group = insert(:group) + %Member{} = member = insert(:member, actor: remote_actor, parent: group) + %Post{} = post = insert(:post, attributed_to: group) + + data = Convertible.model_to_as(post) + refute is_nil(Posts.get_post_by_url(data["id"])) + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() + + object = + data + |> Map.put("name", "My updated post") + |> Map.put("type", "Article") + + update_data = + update_data + |> Map.put("actor", remote_actor_url) + |> Map.put("object", object) + + :error = Transmogrifier.handle_incoming(update_data) + + %Post{id: updated_post_id, title: updated_post_title} = Posts.get_post_by_url(data["id"]) + + assert updated_post_id == post.id + refute updated_post_title == "My updated post" + end + end + + test "it fails for incoming update activities on group posts when the actor is not a member from the group" do + use_cassette "activity_pub/group_post_update_activities" do + %Actor{url: remote_actor_url} = + insert(:actor, + domain: "remote.domain", + url: "https://remote.domain/@remote", + preferred_username: "remote" + ) + + group = insert(:group) + %Post{} = post = insert(:post, attributed_to: group) + + data = Convertible.model_to_as(post) + refute is_nil(Posts.get_post_by_url(data["id"])) + + update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() + + object = + data + |> Map.put("name", "My updated post") + |> Map.put("type", "Article") + + update_data = + update_data + |> Map.put("actor", remote_actor_url) + |> Map.put("object", object) + + :error = Transmogrifier.handle_incoming(update_data) + + %Post{id: updated_post_id, title: updated_post_title} = Posts.get_post_by_url(data["id"]) + + assert updated_post_id == post.id + refute updated_post_title == "My updated post" + end + end + end +end diff --git a/test/federation/activity_pub/transmogrifier_test.exs b/test/federation/activity_pub/transmogrifier_test.exs index cfee647c7..a6d7c808f 100644 --- a/test/federation/activity_pub/transmogrifier_test.exs +++ b/test/federation/activity_pub/transmogrifier_test.exs @@ -10,11 +10,10 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do use Oban.Testing, repo: Mobilizon.Storage.Repo import Mobilizon.Factory - import ExUnit.CaptureLog import Mock import Mox - alias Mobilizon.{Actors, Discussions, Events} + alias Mobilizon.{Actors, Discussions} alias Mobilizon.Actors.Actor alias Mobilizon.Discussions.Comment alias Mobilizon.Events.Event @@ -707,233 +706,6 @@ defmodule Mobilizon.Federation.ActivityPub.TransmogrifierTest do end end - describe "handle incoming update activities" do - test "it works for incoming update activities on actors" do - use_cassette "activity_pub/update_actor_activity" do - data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() - - {:ok, %Activity{data: data, local: false}, _} = Transmogrifier.handle_incoming(data) - update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() - - object = - update_data["object"] - |> Map.put("actor", data["actor"]) - |> Map.put("id", data["actor"]) - - update_data = - update_data - |> Map.put("actor", data["actor"]) - |> Map.put("object", object) - - {:ok, %Activity{data: _data, local: false}, _} = - Transmogrifier.handle_incoming(update_data) - - {:ok, %Actor{} = actor} = Actors.get_actor_by_url(update_data["actor"]) - assert actor.name == "nextsoft" - - assert actor.summary == "<p>Some bio</p>" - end - end - - test "it works for incoming update activities on events" do - use_cassette "activity_pub/event_update_activities" do - data = File.read!("test/fixtures/mobilizon-post-activity.json") |> Jason.decode!() - - {:ok, %Activity{data: data, local: false}, %Event{id: event_id}} = - Transmogrifier.handle_incoming(data) - - assert_enqueued( - worker: Mobilizon.Service.Workers.BuildSearch, - args: %{event_id: event_id, op: :insert_search_event} - ) - - assert %{success: 1, failure: 0} == Oban.drain_queue(queue: :search) - - update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() - - object = - data["object"] - |> Map.put("actor", data["actor"]) - |> Map.put("name", "My updated event") - |> Map.put("id", data["object"]["id"]) - |> Map.put("type", "Event") - - update_data = - update_data - |> Map.put("actor", data["actor"]) - |> Map.put("object", object) - - {:ok, %Activity{data: data, local: false}, _} = - Transmogrifier.handle_incoming(update_data) - - %Event{} = event = Events.get_event_by_url(data["object"]["id"]) - - assert_enqueued( - worker: Mobilizon.Service.Workers.BuildSearch, - args: %{event_id: event_id, op: :update_search_event} - ) - - assert %{success: 1, failure: 0} == Oban.drain_queue(queue: :search) - - assert event.title == "My updated event" - - assert event.description == data["object"]["content"] - end - end - - # test "it works for incoming update activities which lock the account" do - # data = File.read!("test/fixtures/mastodon-post-activity.json") |> Jason.decode!() - - # {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(data) - # update_data = File.read!("test/fixtures/mastodon-update.json") |> Jason.decode!() - - # object = - # update_data["object"] - # |> Map.put("actor", data["actor"]) - # |> Map.put("id", data["actor"]) - # |> Map.put("manuallyApprovesFollowers", true) - - # update_data = - # update_data - # |> Map.put("actor", data["actor"]) - # |> Map.put("object", object) - - # {:ok, %Activity{data: data, local: false}} = Transmogrifier.handle_incoming(update_data) - - # user = User.get_cached_by_ap_id(data["actor"]) - # assert user.info["locked"] == true - # end - end - - describe "handle incoming delete activities" do - test "it works for incoming deletes" do - %Actor{url: actor_url} = - actor = insert(:actor, url: "http://mobilizon.tld/@remote", domain: "mobilizon.tld") - - %Comment{url: comment_url} = - insert(:comment, - actor: nil, - actor_id: actor.id, - url: "http://mobilizon.tld/comments/9f3794b8-11a0-4a98-8cb7-475ab332c701" - ) - - Mock - |> expect(:call, fn - %{method: :get, url: "http://mobilizon.tld/comments/9f3794b8-11a0-4a98-8cb7-475ab332c701"}, - _opts -> - {:ok, %Tesla.Env{status: 410, body: "Gone"}} - end) - - data = - File.read!("test/fixtures/mastodon-delete.json") - |> Jason.decode!() - - object = - data["object"] - |> Map.put("id", comment_url) - - data = - data - |> Map.put("object", object) - |> Map.put("actor", actor_url) - - assert Discussions.get_comment_from_url(comment_url) - assert is_nil(Discussions.get_comment_from_url(comment_url).deleted_at) - - {:ok, %Activity{local: false}, _} = Transmogrifier.handle_incoming(data) - - refute is_nil(Discussions.get_comment_from_url(comment_url).deleted_at) - end - - test "it fails for incoming deletes with spoofed origin" do - comment = insert(:comment) - - announce_data = - File.read!("test/fixtures/mastodon-announce.json") - |> Jason.decode!() - |> Map.put("object", comment.url) - - {:ok, _, _} = Transmogrifier.handle_incoming(announce_data) - - data = - File.read!("test/fixtures/mastodon-delete.json") - |> Jason.decode!() - - object = - data["object"] - |> Map.put("id", comment.url) - - data = - data - |> Map.put("object", object) - - :error = Transmogrifier.handle_incoming(data) - - assert Discussions.get_comment_from_url(comment.url) - end - - setup :set_mox_from_context - - test "it works for incoming actor deletes" do - %Actor{url: url} = - actor = insert(:actor, url: "https://framapiaf.org/users/admin", domain: "framapiaf.org") - - %Event{url: event1_url} = event1 = insert(:event, organizer_actor: actor) - insert(:event, organizer_actor: actor) - - %Comment{url: comment1_url} = comment1 = insert(:comment, actor: actor) - insert(:comment, actor: actor) - - data = - File.read!("test/fixtures/mastodon-delete-user.json") - |> Jason.decode!() - - Mock - |> expect(:call, fn - %{method: :get, url: "https://framapiaf.org/users/admin"}, _opts -> - {:ok, %Tesla.Env{status: 410, body: "Gone"}} - end) - - {:ok, _activity, _actor} = Transmogrifier.handle_incoming(data) - assert %{success: 1, failure: 0} == Oban.drain_queue(queue: :background) - - assert {:error, :actor_not_found} = Actors.get_actor_by_url(url) - assert {:error, :event_not_found} = Events.get_event(event1.id) - # Tombstone are cascade deleted, seems correct for now - # assert %Tombstone{} = Tombstone.find_tombstone(event1_url) - assert %Comment{deleted_at: deleted_at} = Discussions.get_comment(comment1.id) - refute is_nil(deleted_at) - # assert %Tombstone{} = Tombstone.find_tombstone(comment1_url) - end - - test "it fails for incoming actor deletes with spoofed origin" do - %{url: url} = insert(:actor) - deleted_actor_url = "https://framapiaf.org/users/admin" - - data = - File.read!("test/fixtures/mastodon-delete-user.json") - |> Jason.decode!() - |> Map.put("actor", url) - - deleted_actor_data = - File.read!("test/fixtures/mastodon-actor.json") - |> Jason.decode!() - |> Map.put("id", deleted_actor_url) - - Mock - |> expect(:call, fn - %{url: ^deleted_actor_url}, _opts -> - {:ok, %Tesla.Env{status: 200, body: deleted_actor_data}} - end) - - assert capture_log(fn -> - assert :error == Transmogrifier.handle_incoming(data) - end) =~ "Object origin check failed" - - assert Actors.get_actor_by_url(url) - end - end - describe "handle tombstones" do setup :verify_on_exit! diff --git a/test/fixtures/vcr_cassettes/activity_pub/group_post_update_activities.json b/test/fixtures/vcr_cassettes/activity_pub/group_post_update_activities.json new file mode 100644 index 000000000..35e46a0b9 --- /dev/null +++ b/test/fixtures/vcr_cassettes/activity_pub/group_post_update_activities.json @@ -0,0 +1,24 @@ +[ + { + "request": { + "body": "{\"@context\":[\"https://www.w3.org/ns/activitystreams\",\"https://litepub.social/litepub/context.jsonld\",{\"Hashtag\":\"as:Hashtag\",\"PostalAddress\":\"sc:PostalAddress\",\"address\":{\"@id\":\"sc:address\",\"@type\":\"sc:PostalAddress\"},\"addressCountry\":\"sc:addressCountry\",\"addressLocality\":\"sc:addressLocality\",\"addressRegion\":\"sc:addressRegion\",\"anonymousParticipationEnabled\":{\"@id\":\"mz:anonymousParticipationEnabled\",\"@type\":\"sc:Boolean\"},\"category\":\"sc:category\",\"commentsEnabled\":{\"@id\":\"pt:commentsEnabled\",\"@type\":\"sc:Boolean\"},\"discoverable\":\"toot:discoverable\",\"ical\":\"http://www.w3.org/2002/12/cal/ical#\",\"joinMode\":{\"@id\":\"mz:joinMode\",\"@type\":\"mz:joinModeType\"},\"joinModeType\":{\"@id\":\"mz:joinModeType\",\"@type\":\"rdfs:Class\"},\"location\":{\"@id\":\"sc:location\",\"@type\":\"sc:Place\"},\"manuallyApprovesFollowers\":\"as:manuallyApprovesFollowers\",\"maximumAttendeeCapacity\":\"sc:maximumAttendeeCapacity\",\"mz\":\"https://joinmobilizon.org/ns#\",\"participationMessage\":{\"@id\":\"mz:participationMessage\",\"@type\":\"sc:Text\"},\"postalCode\":\"sc:postalCode\",\"pt\":\"https://joinpeertube.org/ns#\",\"repliesModerationOption\":{\"@id\":\"mz:repliesModerationOption\",\"@type\":\"mz:repliesModerationOptionType\"},\"repliesModerationOptionType\":{\"@id\":\"mz:repliesModerationOptionType\",\"@type\":\"rdfs:Class\"},\"sc\":\"http://schema.org#\",\"streetAddress\":\"sc:streetAddress\",\"toot\":\"http://joinmastodon.org/ns#\",\"uuid\":\"sc:identifier\"}],\"actor\":\"http://mobilizon.test/@myGroup0\",\"cc\":[],\"id\":\"http://mobilizon.test/announces/839e0ffc-f437-48db-afba-9ce1e971e938\",\"object\":{\"actor\":\"http://mobilizon.test/@thomas0\",\"attributedTo\":\"http://mobilizon.test/@myGroup0\",\"content\":\"The <b>HTML</b>body for my Article\",\"id\":\"http://mobilizon.test/p/6a482d5f-94fc-446b-84bb-d4d386d5dd45\",\"name\":\"My updated post\",\"published\":\"2020-10-19T08:37:52Z\",\"type\":\"Article\"},\"to\":[\"http://mobilizon.test/@myGroup0/members\"],\"type\":\"Announce\"}", + "headers": { + "Content-Type": "application/activity+json", + "signature": "keyId=\"http://mobilizon.test/@myGroup0#main-key\",algorithm=\"rsa-sha256\",headers=\"(request-target) content-length date digest host\",signature=\"P+7rSSUeUBdX74wbvSEe4roG7yh7MfpF6s4tjv5q1kbeVKtXZRyfC1LqgVNCADZYXFqYlMvfF7DiaRQRiMznGWawM/QXK08eXiAVihYK28Pa56BfI68OUakd+FptlwfB4WJ4Jc7xi1z+iarv+EvlFxjkG5pgwL4mW49rvNnigELzypGtp2bj/2BhiBItHutvOju1MwLR1EBQFJBSZDVZZKbHTcV4KbGtbYvkWUbH8fZbe3fgctKlvO/z9kw+yBTTIEE1O18F4HiJ17nYtaaxv3/vl5RxcjYLpf+QQzkaPOsSLZs8zpIZZp3BbLtPh+OGwkyK9PBQsaI0N1ZSLQ5gaQ==\"", + "digest": "SHA-256=EyZ+uZ/Vv2lUK8ozgOHBpnoUWUM5WQHATQb1tEMldNU=", + "date": "Mon, 19 Oct 2020 08:37:52 GMT" + }, + "method": "post", + "options": [], + "request_body": "", + "url": "http://mobilizon.test/inbox" + }, + "response": { + "binary": false, + "body": "nxdomain", + "headers": [], + "status_code": null, + "type": "error" + } + } +] \ No newline at end of file diff --git a/test/graphql/resolvers/resource_test.exs b/test/graphql/resolvers/resource_test.exs index 628914ee0..293b2fe31 100644 --- a/test/graphql/resolvers/resource_test.exs +++ b/test/graphql/resolvers/resource_test.exs @@ -341,7 +341,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ResourceTest do } ) - assert hd(res["errors"])["message"] == "No such resource" + assert hd(res["errors"])["message"] == "Resource not found" end test "get_resource/3 for a non-existing group", %{ @@ -778,7 +778,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ResourceTest do } ) - assert hd(res["errors"])["message"] == "No such resource" + assert hd(res["errors"])["message"] == "Resource not found" end test "delete_resource/3 deletes a folder and children", %{ @@ -828,7 +828,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ResourceTest do } ) - assert hd(res["errors"])["message"] == "No such resource" + assert hd(res["errors"])["message"] == "Resource not found" res = conn @@ -841,7 +841,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ResourceTest do } ) - assert hd(res["errors"])["message"] == "No such resource" + assert hd(res["errors"])["message"] == "Resource not found" end test "delete_resource/3 deletes a resource not found", %{