Skip to content

Commit

Permalink
Create a new endpoint to return the events of the given calendar iden…
Browse files Browse the repository at this point in the history
…tifier.
  • Loading branch information
Burgy Benjamin committed Dec 16, 2023
1 parent e090027 commit f9f7c7e
Show file tree
Hide file tree
Showing 13 changed files with 251 additions and 27 deletions.
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,41 @@ Project providing a single endpoint with a single API configuration to access to
http://localhost:4000/files/<your-kdrive-file-id>
```

## Calendar

```
http://localhost:4000/calendars/<calendar_id>?from=<iso8601-datetime>&to=<iso8601-datetime>
```

## 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=<secret>
- PHX_HOST=<host>
- PORT=<port>
- KDRIVE_ID=<id>
- KSUITE_API_TOKEN=<token>
- CALDAV_USERNAME=<username>
- CALDAV_PASSWORD=<password>
- CALDAV_SERVER=<server>
- TIMEZONE=<tz>
ports:
- 4000:4000
```
Expand Down
3 changes: 3 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
10 changes: 7 additions & 3 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
8 changes: 6 additions & 2 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<secret>
- PHX_HOST=<host>
- PORT=<port>
- KDRIVE_ID=<id>
- KSUITE_API_TOKEN=<token>
- CALDAV_USERNAME=<username>
- CALDAV_PASSWORD=<password>
- CALDAV_SERVER=<server>
- TIMEZONE=<tz>
ports:
- 4000:4000
3 changes: 2 additions & 1 deletion lib/ksuite_middleware/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 9 additions & 10 deletions lib/ksuite_middleware/ksuite_client.ex
Original file line number Diff line number Diff line change
@@ -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
77 changes: 77 additions & 0 deletions lib/ksuite_middleware/state.ex
Original file line number Diff line number Diff line change
@@ -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
101 changes: 101 additions & 0 deletions lib/ksuite_middleware_web/controllers/calendar_controller.ex
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions lib/ksuite_middleware_web/models/ksuite_calendar_event.ex
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions lib/ksuite_middleware_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -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"},
Expand All @@ -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"},
Expand Down

0 comments on commit f9f7c7e

Please sign in to comment.