From 1aa699fef095666aba97f15136369e33ca356ce0 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Fri, 26 Mar 2021 15:40:10 +0100 Subject: [PATCH] Introduce instance ICS & Atom feeds (disabled by default) And refactor the feed modules Signed-off-by: Thomas Citharel --- .sobelow-skips | 3 +- config/config.exs | 1 + lib/mobilizon/posts/posts.ex | 22 ++- lib/service/export/common.ex | 77 ++++++++++ lib/service/export/feed.ex | 97 +++++++------ lib/service/export/icalendar.ex | 185 +++++++++++++------------ lib/web/controllers/feed_controller.ex | 90 ++++++------ lib/web/router.ex | 1 + test/service/export/icalendar_test.exs | 28 +++- 9 files changed, 319 insertions(+), 185 deletions(-) create mode 100644 lib/service/export/common.ex diff --git a/.sobelow-skips b/.sobelow-skips index 2141d476f..0d0acdd80 100644 --- a/.sobelow-skips +++ b/.sobelow-skips @@ -2,4 +2,5 @@ 5048AE33D6269B15E21CF28C6F545AB6 752C0E897CA81ACD81F4BB215FA5F8E4 -23412CF16549E4E88366DC9DECF39071 \ No newline at end of file +23412CF16549E4E88366DC9DECF39071 +81C1F600C5809C7029EE32DE4818CD7D \ No newline at end of file diff --git a/config/config.exs b/config/config.exs index 038cd81cf..ea57f6449 100644 --- a/config/config.exs +++ b/config/config.exs @@ -34,6 +34,7 @@ config :mobilizon, :instance, unconfirmed_user_grace_period_hours: 48, activity_expire_days: 365, activity_keep_number: 100, + enable_instance_feeds: false, email_from: "noreply@localhost", email_reply_to: "noreply@localhost" diff --git a/lib/mobilizon/posts/posts.ex b/lib/mobilizon/posts/posts.ex index ac292806b..e967e1ee7 100644 --- a/lib/mobilizon/posts/posts.ex +++ b/lib/mobilizon/posts/posts.ex @@ -10,7 +10,7 @@ defmodule Mobilizon.Posts do import Ecto.Query require Logger - @post_preloads [:author, :attributed_to, :picture, :media] + @post_preloads [:author, :attributed_to, :picture, :media, :tags] import EctoEnum @@ -21,6 +21,14 @@ defmodule Mobilizon.Posts do :private ]) + def list_public_local_posts(page \\ nil, limit \\ nil) do + Post + |> filter_public() + |> filter_local() + |> preload_post_associations() + |> Page.build_page(page, limit) + end + @spec list_posts_for_stream :: Enum.t() def list_posts_for_stream do Post @@ -141,10 +149,20 @@ defmodule Mobilizon.Posts do where(query, [p], p.visibility == ^:public and not p.draft) end + @spec filter_local(Ecto.Query.t()) :: Ecto.Query.t() + defp filter_local(query) do + where(query, [q], q.local == true) + end + defp do_get_posts_for_group(group_id) do Post |> where(attributed_to_id: ^group_id) |> order_by(desc: :inserted_at) - |> preload([p], [:author, :attributed_to, :picture, :media, :tags]) + |> preload_post_associations() + end + + @spec preload_post_associations(Ecto.Query.t(), list()) :: Ecto.Query.t() + defp preload_post_associations(query, associations \\ @post_preloads) do + preload(query, ^associations) end end diff --git a/lib/service/export/common.ex b/lib/service/export/common.ex new file mode 100644 index 000000000..26efe9b38 --- /dev/null +++ b/lib/service/export/common.ex @@ -0,0 +1,77 @@ +defmodule Mobilizon.Service.Export.Common do + @moduledoc """ + Common tools for exportation + """ + + alias Mobilizon.{Actors, Events, Posts, Users} + alias Mobilizon.Actors.Actor + alias Mobilizon.Events.{Event, FeedToken} + alias Mobilizon.Storage.Page + alias Mobilizon.Users.User + + @spec fetch_actor_event_feed(String.t()) :: String.t() + def fetch_actor_event_feed(name) do + with %Actor{} = actor <- Actors.get_local_actor_by_name(name), + {:visibility, true} <- {:visibility, Actor.is_public_visibility?(actor)}, + %Page{elements: events} <- Events.list_public_events_for_actor(actor), + %Page{elements: posts} <- Posts.get_public_posts_for_group(actor) do + {:ok, actor, events, posts} + else + err -> + {:error, err} + end + end + + # Only events, not posts + @spec fetch_events_from_token(String.t()) :: String.t() + def fetch_events_from_token(token) do + with {:ok, _uuid} <- Ecto.UUID.cast(token), + %FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(token) do + case actor do + %Actor{} = actor -> + %{ + type: :actor, + actor: actor, + events: fetch_actor_private_events(actor), + user: user, + token: token + } + + nil -> + with actors <- Users.get_actors_for_user(user), + events <- + actors + |> Enum.map(&fetch_actor_private_events/1) + |> Enum.concat() do + %{type: :user, events: events, user: user, token: token, actor: nil} + end + end + end + end + + @spec fetch_instance_public_content :: {:ok, list(Event.t()), list(Post.t())} + def fetch_instance_public_content do + with %Page{elements: events} <- Events.list_public_local_events(), + %Page{elements: posts} <- Posts.list_public_local_posts() do + {:ok, events, posts} + end + end + + @spec fetch_actor_private_events(Actor.t()) :: list(Event.t()) + def fetch_actor_private_events(%Actor{} = actor) do + actor |> fetch_identity_participations() |> participations_to_events() + end + + @spec fetch_identity_participations(Actor.t()) :: Page.t() + defp fetch_identity_participations(%Actor{} = actor) do + with %Page{} = page <- Events.list_event_participations_for_actor(actor) do + page + end + end + + defp participations_to_events(%Page{elements: participations}) do + participations + |> Enum.map(& &1.event_id) + |> Enum.map(&Events.get_event_with_preload!/1) + end +end diff --git a/lib/service/export/feed.ex b/lib/service/export/feed.ex index de5f23987..30a76b37e 100644 --- a/lib/service/export/feed.ex +++ b/lib/service/export/feed.ex @@ -7,11 +7,11 @@ defmodule Mobilizon.Service.Export.Feed do alias Atomex.{Entry, Feed} - alias Mobilizon.{Actors, Config, Events, Posts, Users} alias Mobilizon.Actors.Actor - alias Mobilizon.Events.{Event, FeedToken} + alias Mobilizon.Config + alias Mobilizon.Events.Event alias Mobilizon.Posts.Post - alias Mobilizon.Storage.Page + alias Mobilizon.Service.Export.Common alias Mobilizon.Users.User alias Mobilizon.Web.Endpoint @@ -43,14 +43,57 @@ defmodule Mobilizon.Service.Export.Feed do end end + def create_cache("instance") do + case fetch_instance_feed() do + {:ok, res} -> + {:commit, res} + + err -> + {:ignore, err} + end + end + + @spec fetch_instance_feed :: {:ok, String.t()} + defp fetch_instance_feed do + case Common.fetch_instance_public_content() do + {:ok, events, posts} -> + {:ok, build_instance_feed(events, posts)} + + err -> + {:error, err} + end + end + + # Build an atom feed from the whole instance and its public events and posts + @spec build_instance_feed(list(), list()) :: String.t() + defp build_instance_feed(events, posts) do + self_url = Endpoint.url() + + title = + gettext("Public feed for %{instance}", + instance: Config.instance_name() + ) + + # Title uses default instance language + self_url + |> Feed.new( + DateTime.utc_now(), + title + ) + |> Feed.link(self_url, rel: "self") + |> Feed.link(self_url, rel: "alternate") + |> Feed.generator(Config.instance_name(), uri: Endpoint.url(), version: version()) + |> Feed.entries(Enum.map(events ++ posts, &get_entry/1)) + |> Feed.build() + |> Atomex.generate_document() + end + @spec fetch_actor_event_feed(String.t()) :: String.t() defp fetch_actor_event_feed(name) do - with %Actor{} = actor <- Actors.get_local_actor_by_name(name), - {:visibility, true} <- {:visibility, Actor.is_public_visibility?(actor)}, - %Page{elements: events} <- Events.list_public_events_for_actor(actor), - %Page{elements: posts} <- Posts.get_public_posts_for_group(actor) do - {:ok, build_actor_feed(actor, events, posts)} - else + case Common.fetch_actor_event_feed(name) do + {:ok, actor, events, posts} -> + {:ok, build_actor_feed(actor, events, posts)} + err -> {:error, err} end @@ -156,41 +199,15 @@ defmodule Mobilizon.Service.Export.Feed do # Only events, not posts @spec fetch_events_from_token(String.t()) :: String.t() defp fetch_events_from_token(token) do - with {:ok, _uuid} <- Ecto.UUID.cast(token), - %FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(token) do - case actor do - %Actor{} = actor -> - events = actor |> fetch_identity_participations() |> participations_to_events() - {:ok, build_actor_feed(actor, events, [], false)} - - nil -> - with actors <- Users.get_actors_for_user(user), - events <- - actors - |> Enum.map(fn actor -> - actor - |> Events.list_event_participations_for_actor() - |> participations_to_events() - end) - |> Enum.concat() do - {:ok, build_user_feed(events, user, token)} - end + with %{events: events, token: token, user: user, actor: actor, type: type} <- + Common.fetch_events_from_token(token) do + case type do + :user -> {:ok, build_user_feed(events, user, token)} + :actor -> {:ok, build_actor_feed(actor, events, [], false)} end end end - defp fetch_identity_participations(%Actor{} = actor) do - with %Page{} = page <- Events.list_event_participations_for_actor(actor) do - page - end - end - - defp participations_to_events(%Page{elements: participations}) do - participations - |> Enum.map(& &1.event_id) - |> Enum.map(&Events.get_event_with_preload!/1) - end - # Build an atom feed from actor and its public events @spec build_user_feed(list(), User.t(), String.t()) :: String.t() defp build_user_feed(events, %User{email: email}, token) do diff --git a/lib/service/export/icalendar.ex b/lib/service/export/icalendar.ex index 902712334..750a2ac27 100644 --- a/lib/service/export/icalendar.ex +++ b/lib/service/export/icalendar.ex @@ -3,83 +3,23 @@ defmodule Mobilizon.Service.Export.ICalendar do Export an event to iCalendar format. """ - alias Mobilizon.{Actors, Config, Events, Users} alias Mobilizon.Actors.Actor alias Mobilizon.Addresses.Address - alias Mobilizon.Events.{Event, FeedToken} + alias Mobilizon.{Config, Events} + alias Mobilizon.Events.Event + alias Mobilizon.Service.Export.Common alias Mobilizon.Service.Formatter.HTML - alias Mobilizon.Storage.Page - alias Mobilizon.Users.User @vendor "Mobilizon #{Config.instance_version()}" - @doc """ - Export a public event to iCalendar format. - - The event must have a visibility of `:public` or `:unlisted` - """ - @spec export_public_event(Event.t()) :: {:ok, String.t()} - def export_public_event(%Event{visibility: visibility} = event) - when visibility in [:public, :unlisted] do - export_event(event) - end - - @spec export_public_event(Event.t()) :: {:error, :event_not_public} - def export_public_event(%Event{}), do: {:error, :event_not_public} - - @doc """ - Export an event to iCalendar format - """ - def export_event(%Event{} = event) do - {:ok, %ICalendar{events: [do_export_event(event)]} |> ICalendar.to_ics(vendor: @vendor)} - end - - @spec do_export_event(Event.t()) :: ICalendar.Event.t() - defp do_export_event(%Event{} = event) do - %ICalendar.Event{ - summary: event.title, - dtstart: event.begins_on, - dtstamp: event.publish_at || DateTime.utc_now(), - dtend: event.ends_on, - description: HTML.strip_tags(event.description), - uid: event.uuid, - url: event.url, - geo: Address.coords(event.physical_address), - location: Address.representation(event.physical_address), - categories: event.tags |> Enum.map(& &1.title) - } - end - - @doc """ - Export a public actor's events to iCalendar format. - - The actor must have a visibility of `:public` or `:unlisted`, as well as the events - """ - @spec export_public_actor(Actor.t()) :: String.t() - def export_public_actor(%Actor{} = actor) do - with {:visibility, true} <- {:visibility, Actor.is_public_visibility?(actor)}, - %Page{elements: events} <- - Events.list_public_events_for_actor(actor) do - {:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()} - end - end - - @spec export_private_actor(Actor.t()) :: String.t() - def export_private_actor(%Actor{} = actor) do - with events <- - actor |> Events.list_event_participations_for_actor() |> participations_to_events() do - {:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()} - end - end - @doc """ Create cache for an actor, an event or an user token """ def create_cache("actor_" <> name) do - with %Actor{} = actor <- Actors.get_local_actor_by_name(name), - {:ok, res} <- export_public_actor(actor) do - {:commit, res} - else + case export_public_actor(name) do + {:ok, res} -> + {:commit, res} + err -> {:ignore, err} end @@ -105,33 +45,96 @@ defmodule Mobilizon.Service.Export.ICalendar do end end - @spec fetch_events_from_token(String.t()) :: String.t() - defp fetch_events_from_token(token) do - with %FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(token) do - case actor do - %Actor{} = actor -> - export_private_actor(actor) + def create_cache("instance") do + case fetch_instance_feed() do + {:ok, res} -> + {:commit, res} - nil -> - with actors <- Users.get_actors_for_user(user), - events <- - actors - |> Enum.map(fn actor -> - actor - |> Events.list_event_participations_for_actor() - |> participations_to_events() - end) - |> Enum.concat() do - {:ok, - %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()} - end - end + err -> + {:ignore, err} end end - defp participations_to_events(%Page{elements: participations}) do - participations - |> Enum.map(& &1.event_id) - |> Enum.map(&Events.get_event_with_preload!/1) + @spec fetch_instance_feed :: {:ok, String.t()} + defp fetch_instance_feed do + case Common.fetch_instance_public_content() do + {:ok, events, _posts} -> + {:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()} + + err -> + {:error, err} + end + end + + @doc """ + Export an event to iCalendar format. + """ + @spec export_event(Event.t()) :: {:ok, String.t()} + def export_event(%Event{} = event), do: {:ok, events_to_ics([event])} + + @doc """ + Export a public event to iCalendar format. + + The event must have a visibility of `:public` or `:unlisted` + """ + @spec export_public_event(Event.t()) :: {:ok, String.t()} + def export_public_event(%Event{visibility: visibility} = event) + when visibility in [:public, :unlisted] do + {:ok, events_to_ics([event])} + end + + @spec export_public_event(Event.t()) :: {:error, :event_not_public} + def export_public_event(%Event{}), do: {:error, :event_not_public} + + @doc """ + Export a public actor's events to iCalendar format. + + The actor must have a visibility of `:public` or `:unlisted`, as well as the events + """ + @spec export_public_actor(String.t()) :: String.t() + def export_public_actor(name) do + case Common.fetch_actor_event_feed(name) do + {:ok, _actor, events, _posts} -> + {:ok, events_to_ics(events)} + + err -> + {:error, err} + end + end + + @spec export_private_actor(Actor.t()) :: String.t() + def export_private_actor(%Actor{} = actor) do + with events <- Common.fetch_actor_private_events(actor) do + {:ok, events_to_ics(events)} + end + end + + @spec fetch_events_from_token(String.t()) :: String.t() + defp fetch_events_from_token(token) do + with %{events: events} <- Common.fetch_events_from_token(token) do + {:ok, events_to_ics(events)} + end + end + + @spec events_to_ics(list(Events.t())) :: String.t() + defp events_to_ics(events) do + %ICalendar{events: events |> Enum.map(&do_export_event/1)} + |> ICalendar.to_ics(vendor: @vendor) + end + + @spec do_export_event(Event.t()) :: ICalendar.Event.t() + defp do_export_event(%Event{} = event) do + %ICalendar.Event{ + summary: event.title, + dtstart: event.begins_on, + dtstamp: event.publish_at || DateTime.utc_now(), + dtend: event.ends_on, + description: HTML.strip_tags(event.description), + uid: event.uuid, + url: event.url, + geo: Address.coords(event.physical_address), + location: Address.representation(event.physical_address), + categories: event.tags |> Enum.map(& &1.title) + } end end diff --git a/lib/web/controllers/feed_controller.ex b/lib/web/controllers/feed_controller.ex index 6d1141a7c..87b8181df 100644 --- a/lib/web/controllers/feed_controller.ex +++ b/lib/web/controllers/feed_controller.ex @@ -5,31 +5,20 @@ defmodule Mobilizon.Web.FeedController do use Mobilizon.Web, :controller plug(:put_layout, false) action_fallback(Mobilizon.Web.FallbackController) + alias Mobilizon.Config - def actor(conn, %{"name" => name, "format" => "atom"}) do - case Cachex.fetch(:feed, "actor_" <> name) do - {status, data} when status in [:commit, :ok] -> - conn - |> put_resp_content_type("application/atom+xml") - |> put_resp_header("content-disposition", "attachment; filename=\"#{name}.atom\"") - |> send_resp(200, data) + @formats ["ics", "atom"] - _ -> - {:error, :not_found} + def instance(conn, %{"format" => format}) when format in @formats do + if Config.get([:instance, :enable_instance_feeds], false) do + return_data(conn, format, "instance", Config.instance_name()) + else + send_resp(conn, 401, "Instance feeds are not enabled.") end end - def actor(conn, %{"name" => name, "format" => "ics"}) do - case Cachex.fetch(:ics, "actor_" <> name) do - {status, data} when status in [:commit, :ok] -> - conn - |> put_resp_content_type("text/calendar") - |> put_resp_header("content-disposition", "attachment; filename=\"#{name}.ics\"") - |> send_resp(200, data) - - _err -> - {:error, :not_found} - end + def actor(conn, %{"format" => format, "name" => name}) when format in @formats do + return_data(conn, format, "actor_" <> name, name) end def actor(_conn, _) do @@ -37,49 +26,50 @@ defmodule Mobilizon.Web.FeedController do end def event(conn, %{"uuid" => uuid, "format" => "ics"}) do - case Cachex.fetch(:ics, "event_" <> uuid) do - {status, data} when status in [:commit, :ok] -> - conn - |> put_resp_content_type("text/calendar") - |> put_resp_header("content-disposition", "attachment; filename=\"event.ics\"") - |> send_resp(200, data) - - _ -> - {:error, :not_found} - end + return_data(conn, "ics", "event_" <> uuid, "event.ics") end def event(_conn, _) do {:error, :not_found} end - def going(conn, %{"token" => token, "format" => "ics"}) do - case Cachex.fetch(:ics, "token_" <> token) do + def going(conn, %{"token" => token, "format" => format}) when format in @formats do + return_data(conn, format, "token_" <> token, "events.#{format}") + end + + def going(_conn, _) do + {:error, :not_found} + end + + defp return_data(conn, "atom", type, filename) do + case Cachex.fetch(:feed, type) do + {status, data} when status in [:commit, :ok] -> + conn + |> put_resp_content_type("application/atom+xml") + |> put_resp_header( + "content-disposition", + "attachment; filename=\"#{filename}.atom\"" + ) + |> send_resp(200, data) + + _err -> + {:error, :not_found} + end + end + + defp return_data(conn, "ics", type, filename) do + case Cachex.fetch(:ics, type) do {status, data} when status in [:commit, :ok] -> conn |> put_resp_content_type("text/calendar") - |> put_resp_header("content-disposition", "attachment; filename=\"events.ics\"") + |> put_resp_header( + "content-disposition", + "attachment; filename=\"#{filename}.ics\"" + ) |> send_resp(200, data) _ -> {:error, :not_found} end end - - def going(conn, %{"token" => token, "format" => "atom"}) do - case Cachex.fetch(:feed, "token_" <> token) do - {status, data} when status in [:commit, :ok] -> - conn - |> put_resp_content_type("application/atom+xml") - |> put_resp_header("content-disposition", "attachment; filename=\"events.atom\"") - |> send_resp(200, data) - - {:ignore, _} -> - {:error, :not_found} - end - end - - def going(_conn, _) do - {:error, :not_found} - end end diff --git a/lib/web/router.ex b/lib/web/router.ex index 54287a012..4b54a1350 100644 --- a/lib/web/router.ex +++ b/lib/web/router.ex @@ -137,6 +137,7 @@ defmodule Mobilizon.Web.Router do get("/@:name/feed/:format", FeedController, :actor) get("/events/:uuid/export/:format", FeedController, :event) get("/events/going/:token/:format", FeedController, :going) + get("/feed/instance/:format", FeedController, :instance) end ## MOBILIZON diff --git a/test/service/export/icalendar_test.exs b/test/service/export/icalendar_test.exs index 1e90a7489..c3aceed1c 100644 --- a/test/service/export/icalendar_test.exs +++ b/test/service/export/icalendar_test.exs @@ -6,7 +6,7 @@ defmodule Mobilizon.Service.ICalendarTest do alias ICalendar.Value alias Mobilizon.Addresses.Address - alias Mobilizon.Events.Event + alias Mobilizon.Events.{Event, FeedToken} alias Mobilizon.Service.Export.ICalendar, as: ICalendarService describe "export an event to ics" do @@ -36,4 +36,30 @@ defmodule Mobilizon.Service.ICalendarTest do assert {:ok, ics} == ICalendarService.export_public_event(event) end end + + describe "export the instance's public events" do + test "succeds" do + %Event{} = event = insert(:event, title: "I'm public") + %Event{} = event2 = insert(:event, visibility: :private, title: "I'm private") + %Event{} = event3 = insert(:event, title: "Another public") + + {:commit, ics} = ICalendarService.create_cache("instance") + assert ics =~ event.title + refute ics =~ event2.title + assert ics =~ event3.title + end + end + + describe "export an actor's events from a token" do + test "an actor feedtoken" do + user = insert(:user) + actor = insert(:actor, user: user) + %FeedToken{token: token} = insert(:feed_token, user: user, actor: actor) + event = insert(:event) + insert(:participant, event: event, actor: actor, role: :participant) + + {:commit, ics} = ICalendarService.create_cache("token_#{token}") + assert ics =~ event.title + end + end end