Current section

Files

Jump to
resx_dropbox lib resx_dropbox.ex
Raw

lib/resx_dropbox.ex

defmodule ResxDropbox do
@moduledoc """
A producer to handle dropbox URIs.
ResxDropbox.open("dbpath:/path/to/file.txt")
ResxDropbox.open("dbid:AAAAAAAAAAAAAAAAAAAAAA")
Add `ResxDropbox` to your list of resx producers.
config :resx,
producers: [ResxDropbox]
### Types
MIME types are inferred from file extension names. Following the behaviour
of `Resx.Producers.File.mime/1`.
### Authorities
Authorities are used to match with a dropbox access token. When no authority
is provided, it will attempt to find an access token for `nil`. These tokens
can be configured by setting the `:token` configuration option for `:resx_dropbox`.
config :resx_dropbox,
token: "TOKEN"
config :resx_dropbox,
token: %{ nil => "TOKEN1", "foo@bar" => "TOKEN2", "main" => "TOKEN3" }
config :resx_dropbox,
token: { MyDropboxTokenRetriever, :to_token, 1 }
The `:token` field should contain either a string which will be the token
used by any authority, or a map of authority keys and token string values,
or a callback function that will be passed the authority and should return
`{ :ok, token }` or `:error` if there is no token for the given authority.
Valid function formats are any callback variant, see `Callback` for more
information.
### Sources
Dropbox sources are dropbox content references with a backup data source, so
if the content no longer exists it will revert back to getting the data from
the source and creating the content again. The data source is any compatible
URI.
ResxDropbox.open("dbpath:/foo.txt?source=ZGF0YTp0ZXh0L3BsYWluO2NoYXJzZXQ9VVMtQVNDSUk7YmFzZTY0LGRHVnpkQT09")
If the source cannot be accessed anymore but the content exists, it will access
the content. If both cannot be accessed then the request will fail.
"""
use Resx.Producer
use Resx.Storer
require Callback
alias Resx.Resource
alias Resx.Resource.Content
alias Resx.Resource.Reference
alias Resx.Resource.Reference.Integrity
defp get_token(name) do
case Application.get_env(:resx_dropbox, :token, %{}) do
to_token when Callback.is_callback(to_token) -> Callback.call(to_token, [name])
tokens when is_map(tokens) -> Map.fetch(tokens, name)
token -> { :ok, token }
end
end
defp to_path(%Reference{ repository: repo }), do: { :ok, repo }
defp to_path(%URI{ scheme: "dbpath", path: nil, authority: authority, query: query }), do: build_repo(authority, { :path, "" }, query)
defp to_path(%URI{ scheme: "dbpath", path: "/", authority: authority, query: query }), do: build_repo(authority, { :path, "" }, query)
defp to_path(%URI{ scheme: "dbpath", path: path, authority: authority, query: query }), do: build_repo(authority, { :path, path }, query)
defp to_path(%URI{ scheme: "dbid", path: "/" }), do: { :error, { :invalid_reference, "no ID" } }
defp to_path(%URI{ scheme: "dbid", path: "/" <> path, authority: authority, query: query }), do: build_repo(authority, { :id, "id:" <> path }, query)
defp to_path(%URI{ scheme: "dbid", path: path, authority: authority, query: query }) when not is_nil(path), do: build_repo(authority, { :id, "id:" <> path }, query)
defp to_path(uri) when is_binary(uri), do: URI.decode(uri) |> URI.parse |> to_path
defp to_path(_), do: { :error, { :invalid_reference, "not a dropbox reference" } }
defp build_repo(authority, path, nil), do: { :ok, { authority, path, nil } }
defp build_repo(_, { :id, _ }, _), do: { :error, { :invalid_reference, "dbid cannot have a source" } }
defp build_repo(authority, path, query) do
with %{ "source" => data } <- URI.decode_query(query),
{ :ok, source } <- Base.decode64(data) do
{ :ok, { authority, path, source } }
else
_ -> { :error, { :invalid_reference, "source is not base64" } }
end
end
defp format_api_error(%{ "error" => %{ ".tag" => "path", "path" => %{ ".tag" => "malformed_path" } } }, _), do: { :error, { :invalid_reference, "invalid path format" } }
defp format_api_error(%{ "error" => %{ ".tag" => "path", "path" => %{ ".tag" => "restricted_content" } } }, _), do: { :error, { :invalid_reference, "content is restricted" } }
defp format_api_error(%{ "error" => %{ ".tag" => "path", "path" => %{ ".tag" => error } } }, path) when error in ["not_found", "not_file", "not_folder"], do: { :error, { :unknown_resource, path } }
defp format_api_error(%{ "error_summary" => summary }, _), do: { :error, { :internal, summary } }
defp format_http_error({ :ok, response = %{ status_code: 400 } }, _, _), do: { :error, { :internal, response.body } }
defp format_http_error({ :ok, response }, path, _) do
case Poison.decode(response.body) do
{ :ok, error } -> format_api_error(error, path)
_ -> { :error, { :internal, response.body } }
end
end
defp format_http_error({ :error, error }, _, action), do: { :error, { :internal, "failed to #{action} due to: #{HTTPoison.Error.message(error)}" } }
defp header(token), do: [{"Authorization", "Bearer #{token}"}]
defp get_header([{ key, value }|_], key), do: value
defp get_header([_|headers], key), do: get_header(headers, key)
defp get_header(_, _), do: nil
defp downcase_headers(headers, normalised \\ [])
defp downcase_headers([], normalised), do: normalised
defp downcase_headers([{ key, value }|headers], normalised), do: downcase_headers(headers, [{ String.downcase(key), value }|normalised])
defp api_result(response), do: downcase_headers(response.headers) |> get_header("dropbox-api-result")
defp format_timestamp(timestamp) do
{ :ok, timestamp, _ } = DateTime.from_iso8601(timestamp)
timestamp
end
defp timestamp(data, :server), do: data["server_modified"] |> format_timestamp
defp timestamp(data, :client), do: data["client_modified"] |> format_timestamp
defp timestamp(data, nil), do: timestamp(data, Application.get_env(:resx_dropbox, :timestamp, :server))
defp get_metadata(path, token), do: HTTPoison.post("https://api.dropboxapi.com/2/files/get_metadata", Poison.encode!(%{ path: path }), [{"Content-Type", "application/json"}|header(token)])
defp download(path, token), do: HTTPoison.post("https://content.dropboxapi.com/2/files/download", "", [{"Dropbox-API-Arg", Poison.encode!(%{ path: path })}|header(token)])
defp upload(path, token, contents, timestamp, mute), do: HTTPoison.post("https://content.dropboxapi.com/2/files/upload", contents, [{"Dropbox-API-Arg", Poison.encode!(%{ path: path, mode: :overwrite, client_modified: timestamp, mute: mute })}|header(token)])
defp delete(path, token), do: HTTPoison.post("https://api.dropboxapi.com/2/files/delete", Poison.encode!(%{ path: path }), [{"Content-Type", "application/json"}|header(token)])
@impl Resx.Producer
def schemes(), do: ["dbpath", "dbid"]
@doc """
Opens a dropbox resource.
The `:timestamp` option allows for choosing between `:server` or `:client`
timestamps. By default the server timestamp is used, or whatever application
timestamp setting was given.
config :resx_dropbox,
timestamp: :client
If it is a source reference then a `:mute` option may be passed, which expects
a boolean indicating whether the action should appear in the dropbox change
history or not.
"""
@impl Resx.Producer
def open(reference, opts \\ []) do
with { :path, { :ok, repo = { name, { _, path }, _ } } } <- { :path, to_path(reference) },
{ :token, { :ok, token }, _ } <- { :token, get_token(name), name },
{ :content, { :ok, response = %HTTPoison.Response{ status_code: 200 } }, _ } <- { :content, download(path, token), repo },
{ :data, { :ok, data } } <- { :data, api_result(response) |> Poison.decode } do
content = %Content{
type: Resx.Producers.File.mime(data["name"]),
data: response.body
}
resource = %Resource{
reference: %Reference{
adapter: __MODULE__,
repository: repo,
integrity: %Integrity{
timestamp: timestamp(data, opts[:timestamp]),
checksum: { :dropbox, data["content_hash"] }
}
},
content: content
}
{ :ok, resource }
else
{ :path, error } -> error
{ :token, _, name } -> { :error, { :invalid_reference, "no token for authority (#{inspect name})" } }
{ :content, error, { _, { _, path }, nil } } -> format_http_error(error, path, "retrieve content")
{ :content, _, { name, { :path, path }, source } } -> Resource.store(source, __MODULE__, auth: name, path: path, mute: opts[:mute])
{ :data, _ } -> { :error, { :internal, "unable to process api result" } }
end
end
@impl Resx.Producer
def exists?(reference) do
with { :path, { :ok, repo = { name, { _, path }, _ } } } <- { :path, to_path(reference) },
{ :token, { :ok, token }, _ } <- { :token, get_token(name), name },
{ :metadata, { :ok, %HTTPoison.Response{ status_code: 200 } }, _ } <- { :metadata, get_metadata(path, token), repo } do
{ :ok, true }
else
{ :path, error } -> error
{ :token, _, name } -> { :error, { :invalid_reference, "no token for authority (#{inspect name})" } }
{ :metadata, error, { _, { _, path }, nil } } ->
case format_http_error(error, path, "retrieve metadata") do
{ :error, { :unknown_resource, _ } } -> { :ok, false }
error -> error
end
{ :metadata, _, { _, _, source } } -> Resource.exists?(source)
end
end
@doc """
See if two references are alike.
This will check if two references are referring to the same content regardless
of if they're not of the same kind (aren't both paths or ids) or have different
access tokens (two different accounts referencing the same shared file). Due to
this not all the comparisons can be made without making an API request, if
there is ever a failure accessing that API then the function will assume that
the two references are not alike.
"""
@impl Resx.Producer
def alike?(a, b) do
with { :a, { :ok, repo_a } } <- { :a, to_path(a) },
{ :b, { :ok, repo_b } } <- { :b, to_path(b) } do
case { repo_a, repo_b } do
{ repo, repo } -> true
{ { _, { :id, id }, _ }, { _, { :id, id }, _ } } -> true
{ { name_a, path_a, _ }, { name_b, path_b, _ } } ->
with { :token_a, { :ok, token_a }, _ } <- { :token_a, get_token(name_a), name_a },
{ :token_b, { :ok, token_b }, _ } <- { :token_b, get_token(name_b), name_b } do
a = case path_a do
^path_b when token_a == token_b -> true
{ :id, id } -> id
{ :path, path } ->
with { :metadata, { :ok, response = %HTTPoison.Response{ status_code: 200 } }, _ } <- { :metadata, get_metadata(path, token_a), path },
{ :data, { :ok, data } } <- { :data, response.body |> Poison.decode } do
data["id"]
else
_ -> false
end
end
case { path_b, a } do
{ _, false } -> false
{ _, true } -> true
{ { :id, id }, id } -> true
{ { :id, _ }, _ } -> false
{ { :path, path }, id } ->
with { :metadata, { :ok, response = %HTTPoison.Response{ status_code: 200 } }, _ } <- { :metadata, get_metadata(path, token_b), path },
{ :data, { :ok, data } } <- { :data, response.body |> Poison.decode } do
data["id"] == id
else
_ -> false
end
end
else
_ -> false
end
end
else
_ -> false
end
end
@impl Resx.Producer
def source(reference) do
case to_path(reference) do
{ :ok, { _, _, source } } -> { :ok, source }
error -> error
end
end
@impl Resx.Producer
def resource_uri(reference) do
case to_path(reference) do
{ :ok, { nil, { :id, id }, nil } } -> { :ok, URI.encode("db" <> id) }
{ :ok, { authority, { :id, "id:" <> id }, nil } } -> { :ok, URI.encode("dbid://" <> authority <> "/" <> id) }
{ :ok, { nil, { :path, path }, nil } } -> { :ok, URI.encode("dbpath:" <> path) }
{ :ok, { authority, { :path, path }, nil } } -> { :ok, URI.encode("dbpath://" <> authority <> path) }
{ :ok, { authority, { :path, path }, source } } ->
case Resource.uri(source) do
{ :ok, uri } ->
case authority do
nil -> { :ok, URI.encode("dbpath:" <> path <> "?source=#{Base.encode64(uri)}") }
authority -> { :ok, URI.encode("dbpath://" <> authority <> path <> "?source=#{Base.encode64(uri)}") }
end
error -> error
end
error -> error
end
end
@impl Resx.Producer
def resource_attributes(reference) do
with { :path, { :ok, repo = { name, { _, path }, _ } } } <- { :path, to_path(reference) },
{ :token, { :ok, token }, _ } <- { :token, get_token(name), name },
{ :metadata, { :ok, metadata = %HTTPoison.Response{ status_code: 200 } }, _ } <- { :metadata, get_metadata(path, token), repo },
{ :data, { :ok, data } } <- { :data, metadata.body |> Poison.decode } do
{ :ok, data }
else
{ :path, error } -> error
{ :token, _, name } -> { :error, { :invalid_reference, "no token for authority (#{inspect name})" } }
{ :metadata, error, { _, { _, path }, nil } } -> format_http_error(error, path, "retrieve metadata")
{ :metadata, _, { _, _, source } } -> Resource.attributes(source)
{ :data, _ } -> { :error, { :internal, "unable to process api result" } }
end
end
@doc """
Store a resource as a file in dropbox.
The required options are:
* `:path` - expects a string denoting the path the file will be stored at.
The following options are all optional:
* `:auth` - expects the authority to lookup the token of.
* `:mute` - expects a boolean indicating whether the action should appear in
the dropbox change history or not.
"""
@impl Resx.Storer
def store(resource, options) do
with { :path, { :ok, path } } <- { :path, Keyword.fetch(options, :path) },
name <- options[:auth],
{ :token, { :ok, token }, _ } <- { :token, get_token(name), name },
mute <- options[:mute] || false,
data <- resource.content |> Content.reducer |> Enum.into(<<>>),
meta_path <- path <> ".meta",
timestamp <- DateTime.truncate(resource.reference.integrity.timestamp, :second) |> DateTime.to_iso8601,
{ :upload_meta, { :ok, %HTTPoison.Response{ status_code: 200 } }, _ } <- { :upload_meta, upload(meta_path, token, :erlang.term_to_binary(resource.meta), timestamp, mute), meta_path },
{ :upload_content, { :ok, %HTTPoison.Response{ status_code: 200 } }, _ } <- { :upload_content, upload(path, token, data, timestamp, mute), path } do
content = %Content{
type: Resx.Producers.File.mime(path),
data: data
}
reference = %Reference{
adapter: __MODULE__,
repository: { name, { :path, path }, resource.reference },
integrity: %Integrity{
timestamp: DateTime.utc_now
}
}
{ :ok, %{ resource | reference: reference, content: content } }
else
{ :path, _ } -> { :error, { :internal, "a store :path must be specified" } }
{ :token, _, name } -> { :error, { :invalid_reference, "no token for authority (#{inspect name})" } }
{ :upload_content, error, path } -> format_http_error(error, path, "upload content")
{ :upload_meta, error, path } -> format_http_error(error, path, "upload meta")
end
end
@doc """
Discard a dropbox resource.
The following options are all optional:
* `:meta` - specify whether the meta file should also be deleted. By default
it is.
* `:content` - specify whether the content file should also be deleted. By
default it is.
"""
@impl Resx.Storer
@spec discard(Resx.ref, [meta: boolean, content: boolean]) :: :ok | Resx.error(Resx.resource_error | Resx.reference_error)
def discard(reference, opts) do
with { :path, { :ok, { name, { _, path }, _ } } } <- { :path, to_path(reference) },
{ :token, { :ok, token }, _ } <- { :token, get_token(name), name },
{ :delete, { :ok, %HTTPoison.Response{ status_code: 200 } }, _, _ } <- { :delete, if(opts[:meta] != false, do: delete(path <> ".meta", token), else: { :ok, %HTTPoison.Response{ status_code: 200 } }), path, "meta" },
{ :delete, { :ok, %HTTPoison.Response{ status_code: 200 } }, _, _ } <- { :delete, if(opts[:content] != false, do: delete(path, token), else: { :ok, %HTTPoison.Response{ status_code: 200 } }), path, "content" } do
:ok
else
{ :path, error } -> error
{ :token, _, name } -> { :error, { :invalid_reference, "no token for authority (#{inspect name})" } }
{ :delete, error, path, kind } -> format_http_error(error, path, "delete #{kind}")
end
end
end