Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- defmodule Dave.WebdavPlug do
- use Plug.Router
- import SweetXml
- alias XmlBuilder, as: Xml
- plug :match
- plug :dispatch
- defp document_root do
- Application.get_env(:dave, :document_root, "/data")
- end
- match _ do
- case conn.method do
- "OPTIONS" -> handle_options(conn)
- "PROPFIND" -> handle_propfind(conn)
- "GET" -> handle_get(conn)
- "HEAD" -> handle_head(conn)
- _ -> send_resp(conn, 405, "Method Not Allowed")
- end
- end
- defp handle_options(conn) do
- IO.puts("OPTIONS #{conn.request_path}")
- conn
- |> put_resp_header("dav", "1, 2")
- |> put_resp_header("allow", "OPTIONS, PROPFIND, GET, HEAD")
- |> put_resp_header("ms-author-via", "DAV")
- |> send_resp(200, "")
- end
- defp handle_get(conn) do
- real_path = map_to_filesystem(conn.request_path)
- IO.puts("GET #{conn.request_path} -> #{real_path}")
- case File.stat(real_path) do
- {:ok, %File.Stat{type: :regular, size: size}} ->
- conn =
- conn
- |> put_resp_header("accept-ranges", "bytes")
- |> put_resp_header("content-type", get_content_type(real_path))
- case Plug.Conn.get_req_header(conn, "range") do
- [range_header | _] ->
- send_file_range(conn, real_path, size, range_header)
- _ ->
- conn
- |> put_resp_header("content-length", to_string(size))
- |> send_file(200, real_path)
- end
- {:ok, %File.Stat{type: :directory}} ->
- send_resp(conn, 403, "Directory listing forbidden")
- _ ->
- send_resp(conn, 404, "Not Found")
- end
- end
- defp handle_head(conn) do
- real_path = map_to_filesystem(conn.request_path)
- IO.puts("HEAD #{conn.request_path} -> #{real_path}")
- case File.stat(real_path) do
- {:ok, %File.Stat{type: :regular, size: size}} ->
- conn
- |> put_resp_header("accept-ranges", "bytes")
- |> put_resp_header("content-type", get_content_type(real_path))
- |> put_resp_header("content-length", to_string(size))
- |> send_resp(200, "")
- {:ok, %File.Stat{type: :directory}} ->
- conn
- |> put_resp_header("content-type", "httpd/unix-directory")
- |> send_resp(200, "")
- _ ->
- send_resp(conn, 404, "Not Found")
- end
- end
- defp handle_propfind(conn) do
- path = conn.request_path
- depth = get_depth_header(conn)
- IO.puts("\n=== PROPFIND #{path} (Depth: #{inspect(depth)}) ===")
- {:ok, body, conn} = Plug.Conn.read_body(conn)
- requested_props = parse_propfind_body(body)
- IO.puts("Requested props: #{inspect(requested_props)}")
- resources = get_resources(path, depth)
- IO.puts("Found #{length(resources)} resources")
- response_xml = build_multistatus_response(resources, requested_props, conn)
- conn
- |> put_resp_content_type("application/xml; charset=utf-8")
- |> put_resp_header("dav", "1, 2")
- |> send_resp(207, response_xml)
- end
- defp send_file_range(conn, path, file_size, range_header) do
- case parse_range_header(range_header, file_size) do
- {:ok, start, stop} ->
- length = stop - start + 1
- IO.puts("Range request: bytes #{start}-#{stop}/#{file_size}")
- conn
- |> put_resp_header("content-range", "bytes #{start}-#{stop}/#{file_size}")
- |> put_resp_header("content-length", to_string(length))
- |> send_file(206, path, start, length)
- :error ->
- conn
- |> put_resp_header("content-range", "bytes */#{file_size}")
- |> send_resp(416, "Range Not Satisfiable")
- end
- end
- defp parse_range_header(header, file_size) do
- case Regex.run(~r/bytes=(\d+)-(\d*)/, header) do
- [_, start_str, ""] ->
- start = String.to_integer(start_str)
- if start < file_size do
- {:ok, start, file_size - 1}
- else
- :error
- end
- [_, start_str, stop_str] ->
- start = String.to_integer(start_str)
- stop = String.to_integer(stop_str)
- if start <= stop && start < file_size do
- {:ok, start, min(stop, file_size - 1)}
- else
- :error
- end
- _ ->
- :error
- end
- end
- defp get_content_type(path) do
- case Path.extname(path) |> String.downcase() do
- ".mkv" -> "video/x-matroska"
- ".mp4" -> "video/mp4"
- ".avi" -> "video/x-msvideo"
- ".webm" -> "video/webm"
- ".mp3" -> "audio/mpeg"
- ".flac" -> "audio/flac"
- ".jpg" -> "image/jpeg"
- ".jpeg" -> "image/jpeg"
- ".png" -> "image/png"
- ".txt" -> "text/plain"
- ".srt" -> "text/plain"
- ".vtt" -> "text/vtt"
- _ -> "application/octet-stream"
- end
- end
- defp map_to_filesystem(url_path) do
- # URL decode the path to handle spaces and special characters
- decoded_path = URI.decode(url_path)
- relative_path = String.trim_leading(decoded_path, "/")
- full_path = Path.join(document_root(), relative_path)
- IO.puts(" Mapping: #{url_path} -> #{full_path}")
- full_path
- end
- defp get_depth_header(conn) do
- case Plug.Conn.get_req_header(conn, "depth") do
- ["0"] -> 0
- ["1"] -> 1
- ["infinity"] -> :infinity
- _ -> :infinity
- end
- end
- defp parse_propfind_body(""), do: :allprop
- defp parse_propfind_body(body) do
- try do
- doc = SweetXml.parse(body)
- cond do
- xpath(doc, ~x"//propfind/allprop"e) != nil ->
- :allprop
- xpath(doc, ~x"//propfind/propname"e) != nil ->
- :propname
- true ->
- # Parse the requested properties - simpler approach
- case xpath(doc, ~x"//propfind/prop/*"el) do
- nil -> :allprop
- [] -> :allprop
- props ->
- Enum.map(props, fn elem ->
- # Get element name without namespace
- {:xmlElement, name, _, _, _, _, _, _, _, _, _, _} = elem
- {to_string(name), []}
- end)
- end
- end
- rescue
- e ->
- IO.puts("Parse error: #{inspect(e)}")
- :allprop
- end
- end
- defp get_resources(url_path, depth) do
- real_path = map_to_filesystem(url_path)
- case File.stat(real_path) do
- {:ok, stat} ->
- resources = [{url_path, real_path, stat}]
- if stat.type == :directory && depth != 0 do
- children = list_directory_contents(real_path, url_path, depth)
- resources ++ children
- else
- resources
- end
- {:error, reason} ->
- IO.puts(" Failed to stat #{real_path}: #{inspect(reason)}")
- []
- end
- end
- defp list_directory_contents(dir_path, url_path, depth) do
- case File.ls(dir_path) do
- {:ok, entries} ->
- entries
- |> Enum.reject(&String.starts_with?(&1, "._"))
- |> Enum.flat_map(fn entry ->
- child_real_path = Path.join(dir_path, entry)
- # URL encode the entry name for the URL path
- encoded_entry = URI.encode(entry, &URI.char_unreserved?/1)
- child_url_path =
- if url_path == "/" do
- "/#{encoded_entry}"
- else
- "#{url_path}/#{encoded_entry}"
- end
- case File.stat(child_real_path) do
- {:ok, stat} ->
- if stat.type == :directory && depth == :infinity do
- [{child_url_path, child_real_path, stat} |
- list_directory_contents(child_real_path, child_url_path, depth)]
- else
- [{child_url_path, child_real_path, stat}]
- end
- _ ->
- []
- end
- end)
- {:error, reason} ->
- IO.puts(" Failed to list directory #{dir_path}: #{inspect(reason)}")
- []
- end
- end
- defp build_multistatus_response(resources, requested_props, conn) do
- responses = Enum.map(resources, fn {url_path, real_path, stat} ->
- build_response_element(url_path, real_path, stat, requested_props, conn)
- end)
- Xml.element("D:multistatus", %{"xmlns:D" => "DAV:"}, responses)
- |> Xml.generate()
- end
- defp build_response_element(url_path, real_path, stat, requested_props, conn) do
- href = build_href(url_path, stat)
- props = build_properties(real_path, stat, requested_props)
- propstat = Xml.element("D:propstat", [
- Xml.element("D:prop", props),
- Xml.element("D:status", "HTTP/1.1 200 OK")
- ])
- Xml.element("D:response", [
- Xml.element("D:href", href),
- propstat
- ])
- end
- defp build_href(path, stat) do
- # Ensure trailing slash for directories
- if stat.type == :directory && !String.ends_with?(path, "/") do
- path <> "/"
- else
- path
- end
- end
- defp build_properties(real_path, stat, :allprop) do
- base_props = [
- Xml.element("D:displayname", Path.basename(real_path)),
- Xml.element("D:getlastmodified", format_datetime(stat.mtime)),
- Xml.element("D:creationdate", format_datetime(stat.ctime))
- ]
- case stat.type do
- :directory ->
- base_props ++ [
- Xml.element("D:resourcetype", [Xml.element("D:collection")]),
- Xml.element("D:iscollection", "1")
- ]
- :regular ->
- base_props ++ [
- Xml.element("D:resourcetype", []),
- Xml.element("D:getcontentlength", to_string(stat.size)),
- Xml.element("D:getcontenttype", get_content_type(real_path)),
- Xml.element("D:iscollection", "0")
- ]
- _ ->
- base_props ++ [Xml.element("D:resourcetype", [])]
- end
- end
- defp build_properties(_real_path, _stat, :propname) do
- [
- Xml.element("D:displayname"),
- Xml.element("D:getlastmodified"),
- Xml.element("D:creationdate"),
- Xml.element("D:resourcetype"),
- Xml.element("D:getcontentlength"),
- Xml.element("D:getcontenttype"),
- Xml.element("D:iscollection")
- ]
- end
- defp build_properties(real_path, stat, props) when is_list(props) do
- Enum.map(props, fn {name, _attrs} ->
- case name do
- "displayname" ->
- Xml.element("D:displayname", Path.basename(real_path))
- "getlastmodified" ->
- Xml.element("D:getlastmodified", format_datetime(stat.mtime))
- "creationdate" ->
- Xml.element("D:creationdate", format_datetime(stat.ctime))
- "resourcetype" ->
- if stat.type == :directory do
- Xml.element("D:resourcetype", [Xml.element("D:collection")])
- else
- Xml.element("D:resourcetype", [])
- end
- "getcontentlength" when stat.type == :regular ->
- Xml.element("D:getcontentlength", to_string(stat.size))
- "getcontenttype" when stat.type == :regular ->
- Xml.element("D:getcontenttype", get_content_type(real_path))
- "iscollection" ->
- if stat.type == :directory do
- Xml.element("D:iscollection", "1")
- else
- Xml.element("D:iscollection", "0")
- end
- _ -> nil
- end
- end)
- |> Enum.filter(&(&1 != nil))
- end
- defp format_datetime({{year, month, day}, {hour, minute, second}}) do
- :io_lib.format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
- [year, month, day, hour, minute, second])
- |> IO.iodata_to_binary()
- end
- end
Advertisement
Add Comment
Please, Sign In to add comment