diff options
author | Hubert Chathi <hubert@uhoreg.ca> | 2020-08-15 04:19:50 -0400 |
---|---|---|
committer | Hubert Chathi <hubert@uhoreg.ca> | 2020-08-15 04:28:16 -0400 |
commit | 988bf8af92c6f253ae729a044178a19c76648a71 (patch) | |
tree | 04df4a99c2a26a66fd7927a42a25163a6b685171 /lib | |
parent | s/Any/any/ in typespecs (diff) |
allow body to be iolist or a filename, and add media upload/download
Diffstat (limited to 'lib')
-rw-r--r-- | lib/polyjuice/client.ex | 31 | ||||
-rw-r--r-- | lib/polyjuice/client/endpoint.ex | 13 | ||||
-rw-r--r-- | lib/polyjuice/client/endpoint/get_media_download.ex | 133 | ||||
-rw-r--r-- | lib/polyjuice/client/endpoint/post_join.ex | 2 | ||||
-rw-r--r-- | lib/polyjuice/client/endpoint/post_login.ex | 2 | ||||
-rw-r--r-- | lib/polyjuice/client/endpoint/post_media_upload.ex | 75 | ||||
-rw-r--r-- | lib/polyjuice/client/endpoint/post_user_filter.ex | 2 | ||||
-rw-r--r-- | lib/polyjuice/client/endpoint/put_rooms_send.ex | 2 | ||||
-rw-r--r-- | lib/polyjuice/client/endpoint/put_rooms_state.ex | 2 | ||||
-rw-r--r-- | lib/polyjuice/client/media.ex | 99 | ||||
-rw-r--r-- | lib/polyjuice/client/sync.ex | 3 |
11 files changed, 348 insertions, 16 deletions
diff --git a/lib/polyjuice/client.ex b/lib/polyjuice/client.ex index dfe8775..0d3bc75 100644 --- a/lib/polyjuice/client.ex +++ b/lib/polyjuice/client.ex @@ -64,6 +64,8 @@ defmodule Polyjuice.Client do def prefix_r0, do: "_matrix/client/r0" @doc "The unstable client URL prefix" def prefix_unstable, do: "_matrix/client/unstable" + @doc "The r0 media URL prefix" + def prefix_media_r0, do: "_matrix/media/r0" defprotocol API do @moduledoc """ @@ -122,10 +124,11 @@ defmodule Polyjuice.Client do headers: headers, url: url, body: body, - auth_required: auth_required + auth_required: auth_required, + stream_response: stream_response } = Polyjuice.Client.Endpoint.Proto.http_spec(endpoint, base_url) - Logger.debug("calling #{url}") + Logger.debug("calling #{method} #{url}") if auth_required and access_token == nil do {:error, :auth_required} @@ -141,16 +144,21 @@ defmodule Polyjuice.Client do headers end, body, - [:with_body] + [] ) do - {:ok, status_code, resp_headers, body} -> + {:ok, status_code, resp_headers, client_ref} -> Logger.debug("status code #{status_code}") Polyjuice.Client.Endpoint.Proto.transform_http_result( endpoint, status_code, resp_headers, - body + if stream_response do + Polyjuice.Client.hackney_response_stream(client_ref) + else + {:ok, body} = :hackney.body(client_ref) + body + end ) err -> @@ -169,6 +177,19 @@ defmodule Polyjuice.Client do end end + @doc false + def hackney_response_stream(client_ref) do + Stream.unfold( + client_ref, + fn client_ref -> + case :hackney.stream_body(client_ref) do + {:ok, data} -> {data, client_ref} + _ -> nil + end + end + ) + end + @doc """ Synchronize messages from the server. diff --git a/lib/polyjuice/client/endpoint.ex b/lib/polyjuice/client/endpoint.ex index 41c0a86..b344743 100644 --- a/lib/polyjuice/client/endpoint.ex +++ b/lib/polyjuice/client/endpoint.ex @@ -22,16 +22,18 @@ defmodule Polyjuice.Client.Endpoint do - `method` is the HTTP verb - `headers` is a list of the HTTP headers - `url` is the URL to call - - `body` is the HTTP body (if any) - - `transform` is a function to transform the result (status code, headers, content) to a return value + - `body` is the HTTP body (if any) as a binary, or `{:file, filename}` + - `transform` is a function to transform the result (status code, headers, + content) to a return value - `auth_required` indicates whether the end point requires authentication """ @type t :: %__MODULE__{ method: atom, headers: [{String.t(), String.t()}], url: String.t(), - body: String.t(), - auth_required: true | false + body: iodata() | {:file, String.t()} | {Enumerable.t(), integer}, + auth_required: boolean, + stream_response: boolean } @enforce_keys [:method, :headers, :url] defstruct [ @@ -39,7 +41,8 @@ defmodule Polyjuice.Client.Endpoint do :headers, :url, body: "", - auth_required: true + auth_required: true, + stream_response: false ] end diff --git a/lib/polyjuice/client/endpoint/get_media_download.ex b/lib/polyjuice/client/endpoint/get_media_download.ex new file mode 100644 index 0000000..25250f9 --- /dev/null +++ b/lib/polyjuice/client/endpoint/get_media_download.ex @@ -0,0 +1,133 @@ +# Copyright 2020 Hubert Chathi <hubert@uhoreg.ca> +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +defmodule Polyjuice.Client.Endpoint.GetMediaDownload do + @moduledoc """ + Download from the media repository. + + https://matrix.org/docs/spec/client_server/latest#get-matrix-media-r0-download-servername-mediaid + https://matrix.org/docs/spec/client_server/latest#get-matrix-media-r0-download-servername-mediaid-filename + """ + + @type t :: %__MODULE__{ + url: String.t() | URI.t() | {String.t(), String.t()}, + allow_remote: boolean, + filename: String.t() | nil + } + + @enforce_keys [:url] + defstruct [ + :url, + :filename, + allow_remote: true + ] + + defimpl Polyjuice.Client.Endpoint.Proto do + def http_spec( + %{ + url: mxc_url, + allow_remote: allow_remote, + filename: filename + }, + base_url + ) do + e = &URI.encode_www_form/1 + + {server_name, media_id} = + case mxc_url do + _ when is_binary(mxc_url) -> + %{ + scheme: "mxc", + host: host, + path: path + } = URI.parse(mxc_url) + + {host, String.trim_leading(path, "/")} + + %URI{ + scheme: "mxc", + host: host, + path: path + } -> + {host, String.trim_leading(path, "/")} + + {_, _} -> + mxc_url + end + + filename_part = if is_binary(filename), do: "/" <> filename, else: "" + + url = %{ + URI.merge( + base_url, + "#{Polyjuice.Client.prefix_media_r0()}/download/#{e.(server_name)}/#{e.(media_id)}#{ + filename_part + }" + ) + | query: if(allow_remote, do: nil, else: "allow_remote=false") + } + + %Polyjuice.Client.Endpoint.HttpSpec{ + method: :get, + headers: [ + {"Accept", "*/*"} + ], + url: to_string(url), + stream_response: true + } + end + + def transform_http_result(req, status_code, resp_headers, body) do + if status_code == 200 do + {_, content_type} = + Enum.find( + resp_headers, + {nil, "application/octet-stream"}, + fn {name, _} -> String.downcase(name, :ascii) == "content-type" end + ) + + filename = + Enum.find_value( + resp_headers, + fn {name, value} -> + if String.downcase(name, :ascii) == "content-disposition" do + Regex.split(~r";\s*", value) + |> Enum.find_value(fn directive -> + # FIXME: also handle "filename*" + with [d_name, quoted_name] <- String.split(directive, "=", parts: 2), + "filename" <- String.downcase(d_name) do + case Regex.run(~r/"(.*)"/, quoted_name) do + [_, name] -> Regex.replace(~r/\\(.)/, name, "\\1") + nil -> quoted_name + end + # do some basic sanitizing + |> (&Regex.replace(~r/\0/, &1, "")).() + |> Path.basename() + |> (&if(&1 == "", do: nil, else: &1)).() + else + _ -> + nil + end + end) + end + end + ) + + {:ok, filename, content_type, body} + else + Polyjuice.Client.Endpoint.parse_response(req, status_code, resp_headers, Enum.join(body)) + end + end + end +end diff --git a/lib/polyjuice/client/endpoint/post_join.ex b/lib/polyjuice/client/endpoint/post_join.ex index 00d3669..1ddc922 100644 --- a/lib/polyjuice/client/endpoint/post_join.ex +++ b/lib/polyjuice/client/endpoint/post_join.ex @@ -44,7 +44,7 @@ defmodule Polyjuice.Client.Endpoint.PostJoin do e = &URI.encode_www_form/1 body = - Jason.encode!( + Jason.encode_to_iodata!( if third_party_signed do %{"third_party_signed" => third_party_signed} else diff --git a/lib/polyjuice/client/endpoint/post_login.ex b/lib/polyjuice/client/endpoint/post_login.ex index ce093f0..0414cfc 100644 --- a/lib/polyjuice/client/endpoint/post_login.ex +++ b/lib/polyjuice/client/endpoint/post_login.ex @@ -65,7 +65,7 @@ defmodule Polyjuice.Client.Endpoint.PostLogin do ] |> Enum.concat() |> Map.new() - |> Jason.encode!() + |> Jason.encode_to_iodata!() %Polyjuice.Client.Endpoint.HttpSpec{ method: :post, diff --git a/lib/polyjuice/client/endpoint/post_media_upload.ex b/lib/polyjuice/client/endpoint/post_media_upload.ex new file mode 100644 index 0000000..27fca1a --- /dev/null +++ b/lib/polyjuice/client/endpoint/post_media_upload.ex @@ -0,0 +1,75 @@ +# Copyright 2020 Hubert Chathi <hubert@uhoreg.ca> +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +defmodule Polyjuice.Client.Endpoint.PostMediaUpload do + @moduledoc """ + Upload to the media repository. + + https://matrix.org/docs/spec/client_server/latest#post-matrix-media-r0-upload + """ + + @type t :: %__MODULE__{ + filename: String.t(), + data: binary | {:file, String.t()}, + mimetype: String.t() + } + + @enforce_keys [:filename, :data, :mimetype] + defstruct [ + :filename, + :data, + mimetype: "application/octet-stream" + ] + + defimpl Polyjuice.Client.Endpoint.Proto do + def http_spec( + %{ + filename: filename, + data: data, + mimetype: mimetype + }, + base_url + ) do + e = &URI.encode_www_form/1 + + url = %{ + URI.merge( + base_url, + "#{Polyjuice.Client.prefix_media_r0()}/upload" + ) + | query: "filename=#{e.(filename)}" + } + + %Polyjuice.Client.Endpoint.HttpSpec{ + method: :post, + headers: [ + {"Accept", "application/json"}, + {"Content-Type", mimetype} + ], + url: to_string(url), + body: data + } + end + + def transform_http_result(req, status_code, resp_headers, body) do + Polyjuice.Client.Endpoint.parse_response(req, status_code, resp_headers, body) + end + end + + defimpl Polyjuice.Client.Endpoint.BodyParser do + def parse(_req, parsed) do + {:ok, Map.get(parsed, "content_uri")} + end + end +end diff --git a/lib/polyjuice/client/endpoint/post_user_filter.ex b/lib/polyjuice/client/endpoint/post_user_filter.ex index ceab001..22605aa 100644 --- a/lib/polyjuice/client/endpoint/post_user_filter.ex +++ b/lib/polyjuice/client/endpoint/post_user_filter.ex @@ -39,7 +39,7 @@ defmodule Polyjuice.Client.Endpoint.PostUserFilter do base_url ) do e = &URI.encode_www_form/1 - body = Jason.encode!(filter) + body = Jason.encode_to_iodata!(filter) %Polyjuice.Client.Endpoint.HttpSpec{ method: :post, diff --git a/lib/polyjuice/client/endpoint/put_rooms_send.ex b/lib/polyjuice/client/endpoint/put_rooms_send.ex index 855f346..1bb2ee8 100644 --- a/lib/polyjuice/client/endpoint/put_rooms_send.ex +++ b/lib/polyjuice/client/endpoint/put_rooms_send.ex @@ -45,7 +45,7 @@ defmodule Polyjuice.Client.Endpoint.PutRoomsSend do base_url ) do e = &URI.encode_www_form/1 - body = Jason.encode!(message) + body = Jason.encode_to_iodata!(message) %Polyjuice.Client.Endpoint.HttpSpec{ method: :put, diff --git a/lib/polyjuice/client/endpoint/put_rooms_state.ex b/lib/polyjuice/client/endpoint/put_rooms_state.ex index 74407bf..ca87c42 100644 --- a/lib/polyjuice/client/endpoint/put_rooms_state.ex +++ b/lib/polyjuice/client/endpoint/put_rooms_state.ex @@ -45,7 +45,7 @@ defmodule Polyjuice.Client.Endpoint.PutRoomsState do base_url ) do e = &URI.encode_www_form/1 - body = Jason.encode!(content) + body = Jason.encode_to_iodata!(content) %Polyjuice.Client.Endpoint.HttpSpec{ method: :put, diff --git a/lib/polyjuice/client/media.ex b/lib/polyjuice/client/media.ex new file mode 100644 index 0000000..2a37025 --- /dev/null +++ b/lib/polyjuice/client/media.ex @@ -0,0 +1,99 @@ +# Copyright 2020 Hubert Chathi <hubert@uhoreg.ca> +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +defmodule Polyjuice.Client.Media do + @moduledoc """ + Media-related functions. + """ + require Logger + + @doc """ + Upload a file to the media repository. + + `data` may be either a binary, indicating the file contents, or a tuple + `{:file, path}`, where `path` is a path to the file to be uploaded. + + `opts` is a keyword list of options. Recognized options are: + - `filename:` the filename to use for the uploaded file. This is required + when `data` is a binary. If not specified, and `data` is of the form + `{:file, path}`, then the filename defaults to the basename of the path. + - `mimetype:` the mimetype to use for the uploaded file. Defaults to + `application/octet-stream`. + """ + @spec upload( + client_api :: Polyjuice.Client.API.t(), + data :: binary | {:file, String.t()}, + opts :: Keyword.t() + ) :: {:ok, String.t()} | any + def upload(client_api, data, opts \\ []) do + filename = + Keyword.get_lazy(opts, :filename, fn -> + case data do + {:file, name} -> + Path.basename(name) + end + end) + + mimetype = Keyword.get(opts, :mimetype, "application/octet-stream") + + Polyjuice.Client.API.call( + client_api, + %Polyjuice.Client.Endpoint.PostMediaUpload{ + filename: filename, + data: data, + mimetype: mimetype + } + ) + end + + @doc """ + Download a file from the media repository. + + `url` may be either a binary or a `URI` (giving an `mxc://` URI to download), + or a `{server_name, media_id}` tuple. `filename` is an (optional) filename to + request that the server use, and `allow_remote` indicates whether the server + should fetch media from remote servers if necessary (defaults to true). + + If successful, returns a tuple of the form `{:ok, filename, content_type, body}`, + where `body` is a `Stream` such that `Enum.join(body)` is the file contents. + """ + @spec download( + client_api :: Polyjuice.Client.API.t(), + url :: String.t() | URI.t() | {String.t(), String.t()}, + filename :: String.t(), + allow_remote :: boolean + ) :: {:ok, String.t(), String.t(), Stream.t()} | any + def download(client_api, url, filename \\ nil, allow_remote \\ true) do + Polyjuice.Client.API.call( + client_api, + %Polyjuice.Client.Endpoint.GetMediaDownload{ + url: url, + allow_remote: + cond do + is_boolean(filename) -> filename + is_boolean(allow_remote) -> allow_remote + end, + filename: + cond do + is_boolean(filename) -> nil + true -> filename + end + } + ) + end + + # 13.8.2.4 GET /_matrix/media/r0/thumbnail/{serverName}/{mediaId} + # 13.8.2.5 GET /_matrix/media/r0/preview_url + # 13.8.2.6 GET /_matrix/media/r0/config +end diff --git a/lib/polyjuice/client/sync.ex b/lib/polyjuice/client/sync.ex index d56099b..c898a6e 100644 --- a/lib/polyjuice/client/sync.ex +++ b/lib/polyjuice/client/sync.ex @@ -165,7 +165,8 @@ defmodule Polyjuice.Client.Sync do case :hackney.send_request( state.conn_ref, - {if(state.test, do: :put, else: :post), path, headers, Jason.encode!(state.set_filter)} + {if(state.test, do: :put, else: :post), path, headers, + Jason.encode_to_iodata!(state.set_filter)} ) do {:ok, status_code, _resp_headers, client_ref} -> case status_code do |