From f9f7c7eb1263cf9196f9efe32ead794e84848604 Mon Sep 17 00:00:00 2001 From: Burgy Benjamin Date: Wed, 6 Dec 2023 00:58:35 +0100 Subject: [PATCH] Create a new endpoint to return the events of the given calendar identifier. --- README.md | 30 ++++-- config/config.exs | 3 + config/dev.exs | 10 +- config/runtime.exs | 8 +- docker-compose.yml | 6 +- lib/ksuite_middleware/application.ex | 3 +- lib/ksuite_middleware/ksuite_client.ex | 19 ++-- lib/ksuite_middleware/state.ex | 77 +++++++++++++ .../controllers/calendar_controller.ex | 101 ++++++++++++++++++ .../models/ksuite_calendar_event.ex | 6 ++ lib/ksuite_middleware_web/router.ex | 6 ++ mix.exs | 7 +- mix.lock | 2 + 13 files changed, 251 insertions(+), 27 deletions(-) create mode 100644 lib/ksuite_middleware/state.ex create mode 100644 lib/ksuite_middleware_web/controllers/calendar_controller.ex create mode 100644 lib/ksuite_middleware_web/models/ksuite_calendar_event.ex diff --git a/README.md b/README.md index a886be4..d4421ce 100644 --- a/README.md +++ b/README.md @@ -8,27 +8,41 @@ Project providing a single endpoint with a single API configuration to access to http://localhost:4000/files/ ``` +## Calendar + +``` +http://localhost:4000/calendars/?from=&to= +``` + ## Configuration -| Environment variables | Description | -|-----------------------|------------------------------------------------------------------------------------------------------------------------------| -| KDRIVE_ID | The identifier of your KDrive. | -| KSUITE_API_TOKEN | The API token to use the KDrive API. | -| PHX_HOST | The host the web server. (default: example.com) | -| PORT | The port for the web server. (default: 4000) | -| SECRET_KEY_BASE | Secret key used by the [phoenix framework](https://hexdocs.pm/phoenix/deployment.html#handling-of-your-application-secrets). | +| Environment variables | Description | +|-----------------------|----------------------------------------------------------------------------------------------------------------------------------| +| KDRIVE_ID | The identifier of your KDrive. | +| KSUITE_API_TOKEN | The API token to use the KDrive API. | +| PHX_HOST | The host the web server. (default: example.com) | +| PORT | The port for the web server. (default: 4000) | +| SECRET_KEY_BASE | The secret key used by the [phoenix framework](https://hexdocs.pm/phoenix/deployment.html#handling-of-your-application-secrets). | +| CALDAV_USERNAME | The username to connect to the CalDAV server. | +| CALDAV_PASSWORD | The password to connect to the CalDAV server. | +| CALDAV_SERVER | The server CalDAV, e.g. https://foo.bar.com, https://foo.bar.com/file.php, https://foo.bar.com/caldav, ... | +| TIMEZONE | The timezone used to determine the right date time when the server calDAV returns a daily event in UTC. | ```yaml version: '3' services: ksuite-middleware: - image: minidfx/ksuite-middleware:v0.4.0 + image: minidfx/ksuite-middleware:v0.5.0 environment: - SECRET_KEY_BASE= - PHX_HOST= - PORT= - KDRIVE_ID= - KSUITE_API_TOKEN= + - CALDAV_USERNAME= + - CALDAV_PASSWORD= + - CALDAV_SERVER= + - TIMEZONE= ports: - 4000:4000 ``` diff --git a/config/config.exs b/config/config.exs index 44dd233..ac04b71 100644 --- a/config/config.exs +++ b/config/config.exs @@ -32,6 +32,9 @@ config :phoenix, :json_library, Jason # Tesla config :tesla, adapter: Tesla.Adapter.Hackney +# TzData +config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase + # Import environment specific config. This must remain at the bottom # of this file so it overrides the configuration defined above. import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs index c4dafd5..a387ac7 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -52,6 +52,10 @@ config :phoenix, :stacktrace_depth, 20 # Initialize plugs at runtime for faster development compilation config :phoenix, :plug_init_mode, :runtime -config :kdrive_bridge, - kdrive_id: System.get_env("KDRIVE_ID") || raise("The KDRIVE_ID variable was missing"), - kdrive_api_token: System.get_env("KDRIVE_API_TOKEN") || raise("The KDRIVE_API_TOKEN variable was missing") +config :ksuite_middleware, + kdrive_id: "", + ksuite_api_token: "", + caldav_username: "", + caldav_password: "", + caldav_server: "", + timezone: "" diff --git a/config/runtime.exs b/config/runtime.exs index 8e7ca0f..baa640c 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -51,8 +51,12 @@ if config_env() == :prod do secret_key_base: secret_key_base config :ksuite_middleware, - kdrive_id: System.get_env("KDRIVE_ID") || raise("The KDRIVE_ID variable was missing"), - ksuite_api_token: System.get_env("KSUITE_API_TOKEN") || raise("The KSUITE_API_TOKEN variable was missing") + kdrive_id: System.get_env("KDRIVE_ID") || raise("The KDRIVE_ID environment variable was missing"), + ksuite_api_token: System.get_env("KSUITE_API_TOKEN") || raise("The KSUITE_API_TOKEN venvironment ariable was missing"), + caldav_username: System.get_env("CALDAV_USERNAME") || raise("The CALDAV_USERNAME environment variable was missing"), + caldav_password: System.get_env("CALDAV_PASSWORD") || raise("The CALDAV_PASSWORD environment variable was missing"), + caldav_server: System.get_env("CALDAV_SERVER") || raise("The CALDAV_SERVER environment variable was missing"), + timezone: System.get_env("TIMEZONE") || raise("The TIMEZONE environment variable was missing") # ## SSL Support # diff --git a/docker-compose.yml b/docker-compose.yml index b699e81..4532b86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,12 +2,16 @@ version: '3' services: ksuite-middleware: build: . - image: minidfx/ksuite-middleware:v0.4.0 + image: minidfx/ksuite-middleware:v0.5.0 environment: - SECRET_KEY_BASE= - PHX_HOST= - PORT= - KDRIVE_ID= - KSUITE_API_TOKEN= + - CALDAV_USERNAME= + - CALDAV_PASSWORD= + - CALDAV_SERVER= + - TIMEZONE= ports: - 4000:4000 diff --git a/lib/ksuite_middleware/application.ex b/lib/ksuite_middleware/application.ex index f63afd8..7d57326 100644 --- a/lib/ksuite_middleware/application.ex +++ b/lib/ksuite_middleware/application.ex @@ -14,7 +14,8 @@ defmodule KsuiteMiddleware.Application do # Start a worker by calling: KsuiteMiddleware.Worker.start_link(arg) # {KsuiteMiddleware.Worker, arg}, # Start to serve requests, typically the last entry - KsuiteMiddlewareWeb.Endpoint + KsuiteMiddlewareWeb.Endpoint, + KsuiteMiddleware.State ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/ksuite_middleware/ksuite_client.ex b/lib/ksuite_middleware/ksuite_client.ex index b3b1cdf..b62a959 100644 --- a/lib/ksuite_middleware/ksuite_client.ex +++ b/lib/ksuite_middleware/ksuite_client.ex @@ -1,23 +1,22 @@ defmodule KsuiteMiddleware.KsuiteClient do - require Logger use Tesla + alias KsuiteMiddleware.State + + require Logger + plug Tesla.Middleware.BaseUrl, "https://api.infomaniak.com" plug Tesla.Middleware.Logger, debug: false plug Tesla.Middleware.Headers, [{"User-Agent", "ksuite-middleware"}] - plug Tesla.Middleware.Headers, [{"Authorization", "Bearer #{Application.get_env(:ksuite_middleware, :ksuite_api_token)}"}] + plug Tesla.Middleware.Headers, [{"Authorization", "Bearer #{State.get_ksuite_api_token()}"}] plug Tesla.Middleware.Headers, [{"Content-Type", "application/json"}] plug Tesla.Middleware.FollowRedirects, max_redirects: 1 @spec download(integer()) :: {:error, any()} | {:ok, Tesla.Env.t()} - def download(file_id) when is_integer(file_id) do - kdrive_id = Application.get_env(:ksuite_middleware, :kdrive_id) - get("/2/drive/#{kdrive_id}/files/#{file_id}/download") - end + def download(file_id) when is_integer(file_id), + do: get("/2/drive/#{State.get_kdrive_id()}/files/#{file_id}/download") @spec download_as(integer(), bitstring()) :: {:error, any()} | {:ok, Tesla.Env.t()} - def download_as(file_id, as \\ "pdf") when is_integer(file_id) and is_bitstring(as) do - kdrive_id = Application.get_env(:ksuite_middleware, :kdrive_id) - get("/2/drive/#{kdrive_id}/files/#{file_id}/download", query: [as: as]) - end + def download_as(file_id, as \\ "pdf") when is_integer(file_id) and is_bitstring(as), + do: get("/2/drive/#{State.get_kdrive_id()}/files/#{file_id}/download", query: [as: as]) end diff --git a/lib/ksuite_middleware/state.ex b/lib/ksuite_middleware/state.ex new file mode 100644 index 0000000..11b78b6 --- /dev/null +++ b/lib/ksuite_middleware/state.ex @@ -0,0 +1,77 @@ +defmodule KsuiteMiddleware.State do + alias Timex.TimezoneInfo + use GenServer + + # Client + + def start_link(default), do: GenServer.start_link(__MODULE__, default, name: State) + + @spec get_ksuite_api_token() :: String.t() + def get_ksuite_api_token(), do: GenServer.call(State, :get_ksuite_api_token) + + @spec get_kdrive_id() :: String.t() + def get_kdrive_id(), do: GenServer.call(State, :get_kdrive_id) + + @spec get_caldav_client() :: CalDAVClient.Client.t() + def get_caldav_client(), do: GenServer.call(State, :get_caldav_client) + + @spec get_timezone() :: Timex.TimezoneInfo.t() + def get_timezone(), do: GenServer.call(State, :get_timezone) + + # Callbacks + + @impl true + def init(_) do + {:ok, %{}} + end + + @impl true + def handle_call(:get_ksuite_api_token, _from, %{ksuite_api_token: x} = state), + do: {:reply, x, state} + + @impl true + def handle_call(:get_ksuite_api_token, _from, state) do + api_token = Application.get_env(:ksuite_middleware, :ksuite_api_token) + {:reply, api_token, Map.put_new(state, :ksuite_api_token, api_token)} + end + + @impl true + def handle_call(:get_kdrive_id, _from, %{kdrive_id: x} = state), + do: {:reply, x, state} + + @impl true + def handle_call(:get_kdrive_id, _from, state) do + kdrive_id = Application.get_env(:ksuite_middleware, :kdrive_id) + {:reply, kdrive_id, Map.put_new(state, :kdrive_id, kdrive_id)} + end + + @impl true + def handle_call(:get_caldav_client, _from, %{caldav_client: x} = state), + do: {:reply, x, state} + + @impl true + def handle_call(:get_caldav_client, _from, state) do + username = Application.get_env(:ksuite_middleware, :caldav_username) + password = Application.get_env(:ksuite_middleware, :caldav_password) + server = Application.get_env(:ksuite_middleware, :caldav_server) + + client = %CalDAVClient.Client{ + server_url: server, + auth: %CalDAVClient.Auth.Basic{username: username, password: password} + } + + {:reply, client, Map.put_new(state, :caldav_client, client)} + end + + @impl true + def handle_call(:get_timezone, _from, %{timezone: x} = state), do: {:reply, x, state} + + @impl true + def handle_call(:get_timezone, _from, state) do + with %TimezoneInfo{} = timezone <- Application.get_env(:ksuite_middleware, :timezone) |> Timex.Timezone.get() do + {:reply, timezone, Map.put_new(state, :timezone, timezone)} + else + {:error, :time_zone_not_found} -> raise("The environment variable TIMEZONE was missing.") + end + end +end diff --git a/lib/ksuite_middleware_web/controllers/calendar_controller.ex b/lib/ksuite_middleware_web/controllers/calendar_controller.ex new file mode 100644 index 0000000..4824444 --- /dev/null +++ b/lib/ksuite_middleware_web/controllers/calendar_controller.ex @@ -0,0 +1,101 @@ +defmodule KsuiteMiddlewareWeb.CalendarController do + use KsuiteMiddlewareWeb, :controller + + alias KsuiteMiddlewareWeb.Models.KsuiteCalendarEvent + alias KsuiteMiddleware.State + alias Timex.TimezoneInfo + + require Logger + + def get_events(conn, %{"from" => from, "to" => to, "calendar_id" => calendar_id}), + do: + parse_params(from, to, calendar_id) + |> then(&read_events/1) + |> then(&translate_to_events/1) + |> then(&send_response(conn, &1)) + + # Private + + defp read_events({:error, error, reason}), do: {:error, error, reason} + + defp read_events({:ok, from, to, calendar_id}) do + client = State.get_caldav_client() + %CalDAVClient.Client{auth: %CalDAVClient.Auth.Basic{username: username}} = client + calendar_url = CalDAVClient.URL.Builder.build_calendar_url(username, calendar_id) + + Logger.info("Reading the events from the calendar #{calendar_id} ...") + + client |> CalDAVClient.Event.get_events(calendar_url, from, to) + end + + defp send_response(conn, {:ok, events}), do: json(conn, events) + + defp send_response(conn, {:error, error, reason}), + do: + conn + |> put_status(error) + |> json(%{reason: reason}) + + defp send_response(conn, {:error, reason}), + do: + conn + |> put_status(500) + |> json(%{reason: reason}) + + defp parse_params(from, to, calendar_id) do + Logger.info("Parsing the arguments ...") + + with {:ok, from} <- parse_datetime(:invalid_from, from), + {:ok, to} <- parse_datetime(:invalid_to, to), + {:ok, calendar_id} <- parse_calendar_id(calendar_id) do + {:ok, from, to, calendar_id} + else + :invalid_to -> {:error, :bad_request, "The argument 'to' was invalid."} + :invalid_from -> {:error, :bad_request, "The argument 'from' was invalid."} + {:invalid_calendar_id, reason} -> {:error, :bad_request, reason} + end + end + + defp parse_calendar_id(calendar_id) when byte_size(calendar_id) <= 100, do: {:ok, calendar_id} + defp parse_calendar_id(_), do: {:invalid_calendar_id, "The calendar_id was too long."} + + defp parse_datetime(error_atom, datetime) when is_atom(error_atom) and is_bitstring(datetime) do + case Timex.parse!(datetime, "{ISO:Extended:Z}") do + %DateTime{} = x -> {:ok, x} + _ -> error_atom + end + end + + defp translate_to_events({:error, :unauthorized}), do: {:error, :unauthorized, "Unauthorized access to the CalDAV server, double check your credentials."} + defp translate_to_events({:error, :not_found}), do: {:error, :not_found, "The given calendar_id was not found in the given server CalDAV."} + defp translate_to_events({:error, reason}), do: {:error, reason} + defp translate_to_events({:error, error, reason}), do: {:error, error, reason} + defp translate_to_events({:ok, []}), do: {:ok, []} + defp translate_to_events({:ok, icalendar_events}), do: translate_to_events(icalendar_events, []) + defp translate_to_events([], acc) when is_list(acc), do: {:ok, acc} + + defp translate_to_events([head | tail], acc) when is_list(acc) do + %CalDAVClient.Event{icalendar: icalendar_event} = head + [%ICalendar.Event{} = event] = ICalendar.from_ics(icalendar_event) + %ICalendar.Event{summary: summary, dtstart: from, dtend: to, description: description} = event + + # Because the start and end date don't contain the timezone when it is a daily event, we have to recompute the datetime to the configured timezone. + from = translate_date_to_utc(from) + to = translate_date_to_utc(to) + + new_event = %KsuiteCalendarEvent{subject: summary, from: Timex.format!(from, "{ISO:Extended:Z}"), to: Timex.format!(to, "{ISO:Extended:Z}"), description: description} + + translate_to_events(tail, [new_event | acc]) + end + + defp translate_date_to_utc(%DateTime{time_zone: "Etc/UTC", hour: 0, minute: 0, second: 0} = datetime) do + %DateTime{year: y, month: m, day: d} = datetime + %TimezoneInfo{full_name: tz} = State.get_timezone() + + Timex.to_date({y, m, d}) + |> Timex.to_datetime(tz) + |> Timex.Timezone.convert(:utc) + end + + defp translate_date_to_utc(datetime), do: datetime +end diff --git a/lib/ksuite_middleware_web/models/ksuite_calendar_event.ex b/lib/ksuite_middleware_web/models/ksuite_calendar_event.ex new file mode 100644 index 0000000..d634fe5 --- /dev/null +++ b/lib/ksuite_middleware_web/models/ksuite_calendar_event.ex @@ -0,0 +1,6 @@ +defmodule KsuiteMiddlewareWeb.Models.KsuiteCalendarEvent do + @enforce_keys [:subject, :from, :to, :description] + @derive Jason.Encoder + defstruct [:subject, :from, :to, :description] + @type t :: %__MODULE__{subject: String.t(), from: String.t(), to: String.t(), description: String.t()} +end diff --git a/lib/ksuite_middleware_web/router.ex b/lib/ksuite_middleware_web/router.ex index 044fd4e..7af385b 100644 --- a/lib/ksuite_middleware_web/router.ex +++ b/lib/ksuite_middleware_web/router.ex @@ -22,6 +22,12 @@ defmodule KsuiteMiddlewareWeb.Router do get "/:file_id", KdriveController, :pass_thru end + scope "/calendars", KsuiteMiddlewareWeb do + pipe_through :api + + get "/:calendar_id", CalendarController, :get_events + end + # Enable LiveDashboard in development if Application.compile_env(:ksuite_middleware, :dev_routes) do # If you want to use the LiveDashboard in production, you should put diff --git a/mix.exs b/mix.exs index b749c71..592308a 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule KsuiteMiddleware.MixProject do def project do [ app: :ksuite_middleware, - version: "0.4.0", + version: "0.5.0", elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, @@ -40,7 +40,10 @@ defmodule KsuiteMiddleware.MixProject do {:dns_cluster, "~> 0.1.1"}, {:plug_cowboy, "~> 2.5"}, {:tesla, "~> 1.4"}, - {:hackney, "~> 1.17"} + {:hackney, "~> 1.17"}, + {:caldav_client, "~> 2.0"}, + {:icalendar, "~> 1.1.0"}, + {:timex, "~> 3.0"} ] end diff --git a/mix.lock b/mix.lock index adc5113..3a8902e 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ %{ + "caldav_client": {:hex, :caldav_client, "2.0.0", "047544689bf1203ed6bb566f21439933eae941902398b668c7c3dbc6bc9c06f3", [:mix], [{:hackney, "~> 1.18", [hex: :hackney, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7.2", [hex: :sweet_xml, repo: "hexpm", optional: false]}, {:tesla, "~> 1.4", [hex: :tesla, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: true]}, {:xml_builder, "~> 2.2", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm", "03af466a2450754a591742eb5bca67073f438f410caee2c3c92f093e83452c9a"}, "castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"}, "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, @@ -9,6 +10,7 @@ "expo": {:hex, :expo, "0.5.1", "249e826a897cac48f591deba863b26c16682b43711dd15ee86b92f25eafd96d9", [:mix], [], "hexpm", "68a4233b0658a3d12ee00d27d37d856b1ba48607e7ce20fd376958d0ba6ce92b"}, "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, + "icalendar": {:hex, :icalendar, "1.1.2", "5d0afff5d0143c5bd43f18ae32a777bf0fb9a724543ab05229a460d368f0a5e7", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "2060f8e353fdf3047e95a3f012583dc3c0bbd7ca1010e32ed9e9fc5760ad4292"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},