From d3e2f28b498eb89f3f62f199ef299520c0b03c4b Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Wed, 6 Mar 2019 17:07:42 +0100 Subject: [PATCH] Implement public actor ICS endpoint and event ICS export Closes #83 and #84 Signed-off-by: Thomas Citharel --- lib/mobilizon/application.ex | 19 ++++- lib/mobilizon/export/icalendar.ex | 23 ------ .../controllers/feed_controller.ex | 28 +++++++ lib/mobilizon_web/router.ex | 7 +- lib/service/{ => export}/feed.ex | 2 +- lib/service/export/icalendar.ex | 75 +++++++++++++++++ mix.exs | 3 +- mix.lock | 2 +- .../controllers/feed_controller_test.exs | 80 ++++++++++++++++++- 9 files changed, 208 insertions(+), 31 deletions(-) delete mode 100644 lib/mobilizon/export/icalendar.ex rename lib/service/{ => export}/feed.ex (98%) create mode 100644 lib/service/export/icalendar.ex diff --git a/lib/mobilizon/application.ex b/lib/mobilizon/application.ex index 6a8c2252c..9dfd7b228 100644 --- a/lib/mobilizon/application.ex +++ b/lib/mobilizon/application.ex @@ -4,6 +4,7 @@ defmodule Mobilizon.Application do """ use Application import Cachex.Spec + alias Mobilizon.Service.Export.{Feed, ICalendar} # See https://hexdocs.pm/elixir/Application.html # for more information on OTP Applications @@ -29,11 +30,27 @@ defmodule Mobilizon.Application do default: :timer.minutes(60), interval: :timer.seconds(60) ), - fallback: fallback(default: &Mobilizon.Service.Feed.create_cache/1) + fallback: fallback(default: &Feed.create_cache/1) ] ], id: :cache_feed ), + worker( + Cachex, + [ + :ics, + [ + limit: 2500, + expiration: + expiration( + default: :timer.minutes(60), + interval: :timer.seconds(60) + ), + fallback: fallback(default: &ICalendar.create_cache/1) + ] + ], + id: :cache_ics + ), worker( Cachex, [ diff --git a/lib/mobilizon/export/icalendar.ex b/lib/mobilizon/export/icalendar.ex deleted file mode 100644 index 31a1c33f5..000000000 --- a/lib/mobilizon/export/icalendar.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule Mobilizon.Export.ICalendar do - @moduledoc """ - Export an event to iCalendar format - """ - - alias Mobilizon.Events.Event - - @spec export_event(%Event{}) :: String - def export_event(%Event{} = event) do - events = [ - %ICalendar.Event{ - summary: event.title, - dtstart: event.begins_on, - dtend: event.ends_on, - description: event.description, - uid: event.uuid - } - ] - - %ICalendar{events: events} - |> ICalendar.to_ics() - end -end diff --git a/lib/mobilizon_web/controllers/feed_controller.ex b/lib/mobilizon_web/controllers/feed_controller.ex index 66c629664..816baee47 100644 --- a/lib/mobilizon_web/controllers/feed_controller.ex +++ b/lib/mobilizon_web/controllers/feed_controller.ex @@ -17,4 +17,32 @@ defmodule MobilizonWeb.FeedController do |> send_file(404, "priv/static/index.html") end end + + def actor(conn, %{"name" => name, "format" => "ics"}) do + with {status, data} when status in [:ok, :commit] <- + Cachex.fetch(:ics, "actor_" <> name) do + conn + |> put_resp_content_type("text/calendar") + |> send_resp(200, data) + else + _err -> + conn + |> put_resp_content_type("text/html") + |> send_file(404, "priv/static/index.html") + end + end + + def event(conn, %{"uuid" => uuid, "format" => "ics"}) do + with {status, data} when status in [:ok, :commit] <- + Cachex.fetch(:ics, "event_" <> uuid) do + conn + |> put_resp_content_type("text/calendar") + |> send_resp(200, data) + else + _err -> + conn + |> put_resp_content_type("text/html") + |> send_file(404, "priv/static/index.html") + end + end end diff --git a/lib/mobilizon_web/router.ex b/lib/mobilizon_web/router.ex index 3e9fef9ec..2a8627323 100644 --- a/lib/mobilizon_web/router.ex +++ b/lib/mobilizon_web/router.ex @@ -26,8 +26,8 @@ defmodule MobilizonWeb.Router do plug(:accepts, ["html", "activity-json"]) end - pipeline :rss do - plug(:accepts, ["atom", "html"]) + pipeline :atom_and_ical do + plug(:accepts, ["atom", "ics", "html"]) end pipeline :browser do @@ -60,9 +60,10 @@ defmodule MobilizonWeb.Router do end scope "/", MobilizonWeb do - pipe_through(:rss) + pipe_through(:atom_and_ical) get("/@:name/feed/:format", FeedController, :actor) + get("/events/:uuid/export/:format", FeedController, :event) end scope "/", MobilizonWeb do diff --git a/lib/service/feed.ex b/lib/service/export/feed.ex similarity index 98% rename from lib/service/feed.ex rename to lib/service/export/feed.ex index 7cfb4720e..f1a8f801c 100644 --- a/lib/service/feed.ex +++ b/lib/service/export/feed.ex @@ -1,4 +1,4 @@ -defmodule Mobilizon.Service.Feed do +defmodule Mobilizon.Service.Export.Feed do @moduledoc """ Serve Atom Syndication Feeds """ diff --git a/lib/service/export/icalendar.ex b/lib/service/export/icalendar.ex new file mode 100644 index 000000000..3467c3c6a --- /dev/null +++ b/lib/service/export/icalendar.ex @@ -0,0 +1,75 @@ +defmodule Mobilizon.Service.Export.ICalendar do + @moduledoc """ + Export an event to iCalendar format + """ + + alias Mobilizon.Events.Event + alias Mobilizon.Events + alias Mobilizon.Actors.Actor + alias Mobilizon.Actors + + @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, %ICalendar{events: [do_export_event(event)]} |> ICalendar.to_ics()} + end + + @spec export_public_event(Event.t()) :: {:error, :event_not_public} + def export_public_event(%Event{}), do: {:error, :event_not_public} + + @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, + dtend: event.ends_on, + description: event.description, + uid: event.uuid, + categories: [event.category] ++ (event.tags |> Enum.map(& &1.slug)) + } + end + + @doc """ + Export a public actor's events to iCalendar format. + + The events must have a visibility of `:public` or `:unlisted` + """ + # TODO: The actor should also have visibility options + @spec export_public_actor(Actor.t()) :: String.t() + def export_public_actor(%Actor{} = actor) do + with {:ok, events, _} <- Events.get_public_events_for_actor(actor) do + {:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()} + end + end + + @doc """ + Create cache for an actor + """ + 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 + err -> + {:ignore, err} + end + end + + @doc """ + Create cache for an actor + """ + def create_cache("event_" <> uuid) do + with %Event{} = event <- Events.get_event_full_by_uuid(uuid), + {:ok, res} <- export_public_event(event) do + {:commit, res} + else + err -> + {:ignore, err} + end + end +end diff --git a/mix.exs b/mix.exs index 1a4c3e92c..5bb5251be 100644 --- a/mix.exs +++ b/mix.exs @@ -65,7 +65,8 @@ defmodule Mobilizon.Mixfile do {:geo, "~> 3.0"}, {:geo_postgis, "~> 3.1"}, {:timex, "~> 3.0"}, - {:icalendar, "~> 0.6"}, + # Waiting for https://github.com/lpil/icalendar/pull/29 + {:icalendar, git: "git@framagit.org:tcit/icalendar.git"}, {:exgravatar, "~> 2.0.1"}, {:httpoison, "~> 1.0"}, {:json_ld, "~> 0.3"}, diff --git a/mix.lock b/mix.lock index aa038e615..03e81a8a1 100644 --- a/mix.lock +++ b/mix.lock @@ -55,7 +55,7 @@ "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, "http_sign": {:hex, :http_sign, "0.1.1", "b16edb83aa282892f3271f9a048c155e772bf36e15700ab93901484c55f8dd10", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "httpoison": {:hex, :httpoison, "1.5.0", "71ae9f304bdf7f00e9cd1823f275c955bdfc68282bc5eb5c85c3a9ade865d68e", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, - "icalendar": {:hex, :icalendar, "0.7.0", "6acf28c7e38ad1c4515c59e336878fb78bb646c8aa70d2ee3786ea194711a7b7", [:mix], [{:timex, "~> 3.0", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"}, + "icalendar": {:git, "git@framagit.org:tcit/icalendar.git", "7090ac1f72093c6178a67e167ebaed248f60dd64", []}, "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, "jose": {:hex, :jose, "1.9.0", "4167c5f6d06ffaebffd15cdb8da61a108445ef5e85ab8f5a7ad926fdf3ada154", [:mix, :rebar3], [{:base64url, "~> 0.0.1", [hex: :base64url, repo: "hexpm", optional: false]}], "hexpm"}, diff --git a/test/mobilizon_web/controllers/feed_controller_test.exs b/test/mobilizon_web/controllers/feed_controller_test.exs index c1ef12ca9..79fa1fdf2 100644 --- a/test/mobilizon_web/controllers/feed_controller_test.exs +++ b/test/mobilizon_web/controllers/feed_controller_test.exs @@ -4,7 +4,7 @@ defmodule MobilizonWeb.FeedControllerTest do alias MobilizonWeb.Router.Helpers, as: Routes alias MobilizonWeb.Endpoint - describe "/@:preferred_username.atom" do + describe "/@:preferred_username/feed/atom" do test "it returns an RSS representation of the actor's public events", %{conn: conn} do actor = insert(:actor) tag1 = insert(:tag, title: "RSS", slug: "rss") @@ -61,4 +61,82 @@ defmodule MobilizonWeb.FeedControllerTest do assert response(conn, 404) end end + + describe "/@:preferred_username/feed/ics" do + test "it returns an iCalendar representation of the actor's public events", %{conn: conn} do + actor = insert(:actor) + tag1 = insert(:tag, title: "iCalendar", slug: "icalendar") + tag2 = insert(:tag, title: "Apple", slug: "apple") + event1 = insert(:event, organizer_actor: actor, tags: [tag1]) + event2 = insert(:event, organizer_actor: actor, tags: [tag1, tag2]) + + conn = + conn + |> get( + Routes.feed_url(Endpoint, :actor, actor.preferred_username, "ics") + |> URI.decode() + ) + + assert response(conn, 200) =~ "BEGIN:VCALENDAR" + assert response_content_type(conn, :calendar) =~ "charset=utf-8" + + [entry1, entry2] = entries = ExIcal.parse(conn.resp_body) + + Enum.each(entries, fn entry -> + assert entry.summary in [event1.title, event2.title] + end) + + assert entry1.categories == [event1.category, tag1.slug] + assert entry2.categories == [event2.category, tag1.slug, tag2.slug] + end + + test "it returns an iCalendar representation of the actor's public events with the proper accept header", + %{conn: conn} do + actor = insert(:actor) + + conn = + conn + |> put_req_header("accept", "text/calendar") + |> get( + Routes.feed_url(Endpoint, :actor, actor.preferred_username, "ics") + |> URI.decode() + ) + + assert response(conn, 200) =~ "BEGIN:VCALENDAR" + assert response_content_type(conn, :calendar) =~ "charset=utf-8" + end + + test "it doesn't return anything for an not existing actor", %{conn: conn} do + conn = + conn + |> put_req_header("accept", "text/calendar") + |> get("/@notexistent/feed/ics") + + assert response(conn, 404) + end + end + + describe "/events/:uuid/export/ics" do + test "it returns an iCalendar representation of the event", %{conn: conn} do + tag1 = insert(:tag, title: "iCalendar", slug: "icalendar") + tag2 = insert(:tag, title: "Apple", slug: "apple") + event1 = insert(:event, tags: [tag1, tag2]) + + conn = + conn + |> get( + Routes.feed_url(Endpoint, :event, event1.uuid, "ics") + |> URI.decode() + ) + + assert response(conn, 200) =~ "BEGIN:VCALENDAR" + assert response_content_type(conn, :calendar) =~ "charset=utf-8" + + [entry1] = ExIcal.parse(conn.resp_body) + + assert entry1.summary == event1.title + + assert entry1.categories == [event1.category, tag1.slug, tag2.slug] + end + end end