diff --git a/lib/kolab_wopi/api.ex b/lib/kolab_wopi/api.ex index f797c91..60d6822 100644 --- a/lib/kolab_wopi/api.ex +++ b/lib/kolab_wopi/api.ex @@ -1,92 +1,92 @@ defmodule KolabWopi.API do @moduledoc """ An implementation of the Web Application Open Platform Interface (WOPI) API. See https://wopi.readthedocs.io/en/latest/ """ use Plug.Router use Plug.Debugger require Logger plug Plug.Logger plug :access_token_handler plug :match plug :dispatch def init(options) do options end def start_link do {:ok, _} = Plug.Adapters.Cowboy.http __MODULE__, [] end forward "/wopi/files", to: __MODULE__.Files forward "/wopi/containers", to: __MODULE__.Containers forward "/wopi/ecosystem", to: __MODULE__.Ecosystem forward "/wopibootstrapper", to: __MODULE__.Bootstrap match _ do send_status_resp(conn, 404) end @doc """ Plug that fetches query parameters and validates the access_token. The access_token parameter is required in every WOPI request. If it's missing "401 Unauthorized" response will be send and request processing aborted. """ def access_token_handler(conn, _opts) do conn = Plug.Conn.fetch_query_params(conn) token = conn.query_params["access_token"] check_token(conn, token) end # valid token handler defp check_token(conn, token) when is_binary(token) and byte_size(token) > 0 do assign(conn, :access_token, token) end # invalid token handler defp check_token(conn, _) do conn |> send_status_resp(401) |> halt() end @doc """ Sends the response with setting status code. It supports Chwala status codes. """ def send_status_resp(conn, code, reason \\ nil) def send_status_resp(conn, 403, reason) do - # Chwala uses only 200, 403, 500, 501 + # Chwala uses only 200, 403, 500, 501, 503 send_status_resp(conn, 401, reason) end def send_status_resp(conn, code, _reason) do # TODO: log status reason? send_resp(conn, code, "") end @doc """ Return X-WOPI-Override header value. With some buggy collabora versions support. """ def get_wopi_action(conn) do action = case get_req_header(conn, "x-wopi-override") do a when length(a) > 0 -> a _ -> get_req_header(conn, "x-wopioverride") end to_string(action) end end diff --git a/lib/kolab_wopi/api/files.ex b/lib/kolab_wopi/api/files.ex index 9a32157..cba1e93 100644 --- a/lib/kolab_wopi/api/files.ex +++ b/lib/kolab_wopi/api/files.ex @@ -1,544 +1,548 @@ defmodule KolabWopi.API.Files do @moduledoc """ Provides the endpoints for file related API in the WOPI spec """ use Plug.Router alias KolabWopi.Chwala, as: Kolab alias KolabWopi.API plug :match plug :dispatch ### ### Routes handling ### get "/:file_id" do check_file_info(conn, file_id) end get "/:file_id/contents" do get_file(conn, file_id) end get "/:file_id/ancestry" do enumerate_ancestors(conn, file_id) end get "/:file_id/ecosystem_pointer" do get_ecosystem(conn, file_id) end post "/:file_id/contents" do case API.get_wopi_action(conn) do "PUT" -> put_file(conn, file_id) _ -> API.send_status_resp(conn, 501) end end post "/:file_id" do case API.get_wopi_action(conn) do "UNLOCK" -> un_lock(conn, file_id) "LOCK" -> lock(conn, file_id) "GET_LOCK" -> get_lock(conn, file_id) "REFRESH_LOCK" -> refresh_lock(conn, file_id) "PUT_RELATIVE" -> put_relative_file(conn, file_id) "RENAME_FILE" -> rename_file(conn, file_id) "DELETE" -> delete_file(conn, file_id) "PUT_USER_INFO" -> put_user_info(conn, file_id) "GET_SHARE_URL" -> get_share_url(conn, file_id) _ -> API.send_status_resp(conn, 501) end end match _ do API.send_status_resp(conn, 404) end ### ### Action handlers ### @doc """ [GET] CheckFileInfo: Returns information about a file. Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Request Headers: - X-WOPI-SessionContext: The value of the Session context parameter. Status Codes: - 200 Success - 404 Resource not found/user unauthorized JSON Response (only required attributes listed): - BaseFileName: The string name of the file without a path. Used for display in user interface (UI), and determining the extension of the file. - OwnerId: A string that uniquely identifies the owner of the file. - Size: The size of the file in bytes, expressed as a long, a 64-bit signed integer. - UserId: A string value uniquely identifying the user currently accessing the file. - Version: The current version of the file based on the server’s file version schema, as a string. This value must change when the file changes. """ def check_file_info(conn, file_id) do # get file metadata from Chwala case Kolab.document_info(conn.assigns[:access_token], file_id) do {:error, code, reason} -> API.send_status_resp(conn, code, reason) {:ok, response} -> # convert file metadata to Wopi format # TODO: support more properties from # https://wopirest.readthedocs.io/en/latest/files/CheckFileInfo.html response_body = %{ "BaseFileName": Map.get(response, "name"), "Size": Map.get(response, "size"), "Version": Map.get(response, "modified"), "OwnerId": Map.get(response, "owner"), "UserId": Map.get(response, "user"), - "UserFriendlyName": Map.get(response, "user_name") + "UserFriendlyName": Map.get(response, "user_name"), + # Collabora bug workaround, convert boolean to string + # "UserCanWrite": not Map.get(response, "readonly"), + "UserCanWrite": (if not Map.get(response, "readonly"), do: "true", else: "false"), + "PostMessageOrigin": Map.get(response, "origin"), } |> Poison.Encoder.encode([]) # JSON-encode |> to_string() # send response conn |> put_resp_content_type("application/json") |> send_resp(200, response_body) end end @doc """ [GET] GetFile: Retrieves a file from a host. Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Request Headers: - X-WOPI-MaxExpectedSize: An integer specifying the upper bound of the expected size of the file being requested. Optional. The host should use the maximum value of a 4-byte integer if this value is not set in the request. Response Headers: - X-WOPI-ItemVersion: An optional string value indicating the version of the file. Its value should be the same as Version value in CheckFileInfo. Status Codes: - 200 Success - 404 Resource not found/user unauthorized - 412 File is larger than X-WOPI-MaxExpectedSize """ def get_file(conn, file_id, proxy \\ nil) def get_file(conn, file_id, proxy) when not is_nil(proxy) do # proxy file content from Chwala to WOPI client case Kolab.document_get(conn.assigns[:access_token], file_id, self()) do {:ok, _async_response} -> proxy_file(conn) {:error, reason} -> API.send_status_resp(conn, 404, reason) end end def get_file(conn, file_id, _proxy) do # Get file metadata, check preconditions and set response headers case Kolab.document_info(conn.assigns[:access_token], file_id) do {:error, code, reason} -> API.send_status_resp(conn, code, reason) {:ok, response} -> max_expected_size = max_expected_size(conn) case Map.get(response, "size") do s when s <= max_expected_size -> conn |> put_resp_header("X-WOPI-ItemVersion", Map.get(response, "modified")) |> get_file(file_id, true) _ -> API.send_status_resp(conn, 412) # Precondition Failed (File is too big) end end end defp proxy_file(conn) do receive do %HTTPoison.Error{reason: reason} -> # might need to take care of "already sending... then got an error?" API.send_status_resp(conn, 404, reason) %HTTPoison.AsyncStatus{code: code} -> case code do 200 -> proxy_file(conn) _ -> API.send_status_resp(conn, code, "Unauthorized") end %HTTPoison.AsyncHeaders{headers: headers} -> # proxy relevant headers header_names = ["Cache-Control", "Pragma", "Content-Length", "Content-Type"] headers = Enum.filter_map(headers, fn({name, _value}) -> Enum.member?(header_names, name) end, fn({name, value}) -> {String.downcase(name), value} end ) conn |> merge_resp_headers(headers) |> send_chunked(200) |> proxy_file() %HTTPoison.AsyncEnd{} -> # all done! conn %HTTPoison.AsyncChunk{chunk: next_chunk} -> case chunk(conn, next_chunk) do {:error, _term} -> conn _ -> proxy_file(conn) end end end defp max_expected_size(conn) do # WOPI specification says max is around 4GB, I think # it should be smaller, loading/saving such big files will fail anyway max_size = 1024 * 1024 * 1024 max_expected_size = case get_req_header(conn, "x-wopi-maxexpectedsize") do a when length(a) > 0 -> String.to_integer(hd(a)) _ -> max_size end Enum.min([max_expected_size, max_size]) end @doc """ [POST] PutFile: Updates a file’s binary contents. Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Request Headers: - X-WOPI-Override: The string "PUT". - X-WOPI-Lock: A string provided by the WOPI client in a previous Lock request. Note that this header will not be included during document creation. Response Headers: - X-WOPI-Lock: A string value identifying the current lock on the file. This header must always be included when responding to the request with 409. It should not be included when responding to the request with 200 OK. - X-WOPI-LockFailureReason: An optional string value indicating the cause of a lock failure. - X-WOPI-ItemVersion: An optional string value indicating the version of the file. Its value should be the same as Version value in CheckFileInfo. Status Codes: - 200 Success - 404 Resource not found/user unauthorized - 409 Conflict: Lock mismatch/locked by another interface - 413 Request Entity Too Large: File is too large; Host limit exceeded. """ def put_file(conn, file_id, resource \\ nil) do # TODO: nicely handle error 413 case read_body(conn, [:length, 1000, :read_length, 1000]) do {:error, reason} -> API.send_status_resp(conn, 500, reason) {:ok, body, conn} -> result = Kolab.document_put(conn.assigns[:access_token], file_id, body, resource, true) case result do {:error, code, reason} -> API.send_status_resp(conn, code, reason) {:ok, _response} -> API.send_status_resp(conn, 200) end {:more, body, conn} -> # FIXME: this code path was not tested result = Kolab.document_put(conn.assigns[:access_token], file_id, body, resource, false) case result do {:error, code, reason} -> API.send_status_resp(conn, code, reason) {:ok, _response} -> API.send_status_resp(conn, 200) {:continue, resource} -> put_file(conn, file_id, resource) end end end @doc """ [POST] PutRelativeFile: Creates a new file on the host based on the current file. The host must use the content in the POST body. Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Request Headers: - X-WOPI-Override: The string "PUT_RELATIVE". - X-WOPI-SuggestedTarget: A UTF-7 encoded string specifying either a file extension or a full file name, including the file extension. - X-WOPI-RelativeTarget: A UTF-7 encoded string that specifies a full file name including the file extension. - X-WOPI-OverwriteRelativeTarget: A Boolean value that specifies whether the host must overwrite the file name if it exists. - X-WOPI-Size: An integer that specifies the size of the file in bytes. - X-WOPI-FileConversion: A header whose presence indicates that the request is being made in the context of a binary document conversion. Body: The request body must be the full binary contents of the file. Response Headers: - X-WOPI-ValidRelativeTarget: A UTF-7 encoded string that specifies a full file name including the file extension. - X-WOPI-Lock: A string value identifying the current lock on the file. This header must always be included when responding to the request with 409. It should not be included when responding to the request with 200 OK. - X-WOPI-LockFailureReason: An optional string value indicating the cause of a lock failure. Status Codes: - 200 Success - 400 Bad Request: Specified name is illegal - 409 Conflict: Target file already exists or the file is locked. - 413 Request Entity Too Large: File is too large; Host limit exceeded. JSON Response (only required attributes listed): - Name: The string name of the newly created file without a path. - Url: A string URI of the form http://server/<...>/wopi/files/(file_id)?access_token=(access token), of the newly created file on the host. """ def put_relative_file(conn, file_id) do API.send_status_resp(conn, 501) end @doc """ [POST] RenameFile: Renames a file. Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Request Headers: - X-WOPI-Override: The string "RENAME_FILE". - X-WOPI-Lock: A string identifying the lock on the file. - X-WOPI-RequestedName: A UTF-7 encoded string that is a file name, not including the file extension. Response Headers: - X-WOPI-InvalidFileNameError: A string describing the reason the rename operation could not be completed. - X-WOPI-Lock: A string value identifying the current lock on the file. This header must always be included when responding to the request with 409. It should not be included when responding to the request with 200 OK. - X-WOPI-LockFailureReason: An optional string value indicating the cause of a lock failure. Status Codes: - 200 Success - 400 Bad Request: Specified name is illegal - 409 Conflict: Target file already exists or the file is locked. JSON Response (only required attributes listed): - Name: The string name of the renamed file without a path or file extension. """ def rename_file(conn, file_id) do API.send_status_resp(conn, 501) end @doc """ [POST] DeleteFile: Deletes a file. Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Request Headers: - X-WOPI-Override: The string "DELETE". Response Headers: - X-WOPI-Lock: A string value identifying the current lock on the file. This header must always be included when responding to the request with 409. It should not be included when responding to the request with 200 OK. - X-WOPI-LockFailureReason: An optional string value indicating the cause of a lock failure. Status Codes: - 200 Success - 409 Conflict: Target file already exists or the file is locked. """ def delete_file(conn, file_id) do API.send_status_resp(conn, 501) end @doc """ [POST] PutUserInfo: Stores some basic user information on the host. Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Request Headers: - X-WOPI-Override: The string "PUT_USER_INFO". Body: The request body must be the full UserInfo string. Status Codes: - 200 Success """ def put_user_info(conn, file_id) do API.send_status_resp(conn, 501) end @doc """ [POST] Lock: Locks a file for editing. [POST] UnLockAndRelock: Releases a lock on a file, and then immediately takes a new lock on the file Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Request Headers: - X-WOPI-Override: The string "LOCK". - X-WOPI-Lock: A string provided by the WOPI client that the host must use to identify the lock on the file. - X-WOPI-OldLock: A string provided by the WOPI client that is the existing lock on the file. Required for UnLockAndRelock. Note that if X-WOPI-OldLock is not provided, the request is identical to a Lock request. Response Headers: - X-WOPI-Lock: A string value identifying the current lock on the file. This header must always be included when responding to the request with 409. It should not be included when responding to the request with 200 OK. - X-WOPI-LockFailureReason: An optional string value indicating the cause of a lock failure. - X-WOPI-ItemVersion: An optional string value indicating the version of the file. Its value should be the same as Version value in CheckFileInfo. Status Codes: - 200 Success - 400 Bad Request: X-WOPI-Lock was not provided or was empty - 409 Conflict: Lock mismatch/locked by another interface """ def lock(conn, file_id) do API.send_status_resp(conn, 501) end @doc """ [POST] GetLock: Retrieves a lock on a file. Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Request Headers: - X-WOPI-Override: The string "GET_LOCK". Response Headers: - X-WOPI-Lock: A string value identifying the current lock on the file. This header must always be included when responding to the request with 409. It should not be included when responding to the request with 200 OK. - X-WOPI-LockFailureReason: An optional string value indicating the cause of a lock failure. Status Codes: - 200 Success - 409 Conflict: Lock mismatch/locked by another interface """ def get_lock(conn, file_id) do API.send_status_resp(conn, 501) end @doc """ [POST] RefreshLock: Refreshes the lock on a file. Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Request Headers: - X-WOPI-Override: The string "REFRESH_LOCK". - X-WOPI-Lock: A string provided by the WOPI client that the host must use to identify the lock on the file. Response Headers: - X-WOPI-Lock: A string value identifying the current lock on the file. This header must always be included when responding to the request with 409. It should not be included when responding to the request with 200 OK. - X-WOPI-LockFailureReason: An optional string value indicating the cause of a lock failure. Status Codes: - 200 Success - 400 Bad Request: X-WOPI-Lock was not provided or was empty - 409 Conflict: Lock mismatch/locked by another interface """ def refresh_lock(conn, file_id) do API.send_status_resp(conn, 501) end @doc """ [POST] UnLock: Releases the lock on a file. Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Request Headers: - X-WOPI-Override: The string "UNLOCK". - X-WOPI-Lock: A string provided by the WOPI client that the host must use to identify the lock on the file. Response Headers: - X-WOPI-Lock: A string value identifying the current lock on the file. This header must always be included when responding to the request with 409. It should not be included when responding to the request with 200 OK. - X-WOPI-LockFailureReason: An optional string value indicating the cause of a lock failure. Status Codes: - 200 Success - 400 Bad Request: X-WOPI-Lock was not provided or was empty - 409 Conflict: Lock mismatch/locked by another interface """ def un_lock(conn, file_id) do API.send_status_resp(conn, 501) end @doc """ [POST] GetShareUrl: Returns a Share URL that is suitable for viewing a shared file. Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Request Headers: - X-WOPI-Override: The string "GET_SHARE_URL". - X-WOPI-UrlType: A string indicating what Share URL type to return. Status Codes: - 200 Success JSON Response: - ShareUrl: A URI that points to a webpage that allows the user to access the file. """ def get_share_url(conn, file_id) do API.send_status_resp(conn, 501) end @doc """ [GET] EnumerateAncestors Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Response Headers: - X-WOPI-EnumerationIncomplete: An optional header indicating that the enumeration of the container’s ancestry is incomplete. Status Codes: - 200 Success JSON Response (only required attributes listed): - AncestorsWithRootFirst: An array of JSON-formatted objects (Name,Url) """ def enumerate_ancestors(conn, file_id) do API.send_status_resp(conn, 501) end @doc """ [GET] GetEcosystem Parameters: - file_id: A string that specifies a file ID Query parameters: - access_token: Authorization token Status Codes: - 200 Success JSON Response (only required attributes listed): - Url: A string URI for the WOPI server’s ecosystem endpoint. """ def get_ecosystem(conn, file_id) do API.send_status_resp(conn, 501) end end