Guest User

webdav_plug.ex

a guest
Oct 26th, 2025
166
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 11.28 KB | None | 0 0
  1. defmodule Dave.WebdavPlug do
  2. use Plug.Router
  3. import SweetXml
  4. alias XmlBuilder, as: Xml
  5.  
  6. plug :match
  7. plug :dispatch
  8.  
  9. defp document_root do
  10. Application.get_env(:dave, :document_root, "/data")
  11. end
  12.  
  13. match _ do
  14. case conn.method do
  15. "OPTIONS" -> handle_options(conn)
  16. "PROPFIND" -> handle_propfind(conn)
  17. "GET" -> handle_get(conn)
  18. "HEAD" -> handle_head(conn)
  19. _ -> send_resp(conn, 405, "Method Not Allowed")
  20. end
  21. end
  22.  
  23. defp handle_options(conn) do
  24. IO.puts("OPTIONS #{conn.request_path}")
  25. conn
  26. |> put_resp_header("dav", "1, 2")
  27. |> put_resp_header("allow", "OPTIONS, PROPFIND, GET, HEAD")
  28. |> put_resp_header("ms-author-via", "DAV")
  29. |> send_resp(200, "")
  30. end
  31.  
  32. defp handle_get(conn) do
  33. real_path = map_to_filesystem(conn.request_path)
  34. IO.puts("GET #{conn.request_path} -> #{real_path}")
  35.  
  36. case File.stat(real_path) do
  37. {:ok, %File.Stat{type: :regular, size: size}} ->
  38. conn =
  39. conn
  40. |> put_resp_header("accept-ranges", "bytes")
  41. |> put_resp_header("content-type", get_content_type(real_path))
  42.  
  43. case Plug.Conn.get_req_header(conn, "range") do
  44. [range_header | _] ->
  45. send_file_range(conn, real_path, size, range_header)
  46. _ ->
  47. conn
  48. |> put_resp_header("content-length", to_string(size))
  49. |> send_file(200, real_path)
  50. end
  51.  
  52. {:ok, %File.Stat{type: :directory}} ->
  53. send_resp(conn, 403, "Directory listing forbidden")
  54.  
  55. _ ->
  56. send_resp(conn, 404, "Not Found")
  57. end
  58. end
  59.  
  60. defp handle_head(conn) do
  61. real_path = map_to_filesystem(conn.request_path)
  62. IO.puts("HEAD #{conn.request_path} -> #{real_path}")
  63.  
  64. case File.stat(real_path) do
  65. {:ok, %File.Stat{type: :regular, size: size}} ->
  66. conn
  67. |> put_resp_header("accept-ranges", "bytes")
  68. |> put_resp_header("content-type", get_content_type(real_path))
  69. |> put_resp_header("content-length", to_string(size))
  70. |> send_resp(200, "")
  71.  
  72. {:ok, %File.Stat{type: :directory}} ->
  73. conn
  74. |> put_resp_header("content-type", "httpd/unix-directory")
  75. |> send_resp(200, "")
  76.  
  77. _ ->
  78. send_resp(conn, 404, "Not Found")
  79. end
  80. end
  81.  
  82. defp handle_propfind(conn) do
  83. path = conn.request_path
  84. depth = get_depth_header(conn)
  85.  
  86. IO.puts("\n=== PROPFIND #{path} (Depth: #{inspect(depth)}) ===")
  87.  
  88. {:ok, body, conn} = Plug.Conn.read_body(conn)
  89. requested_props = parse_propfind_body(body)
  90. IO.puts("Requested props: #{inspect(requested_props)}")
  91.  
  92. resources = get_resources(path, depth)
  93. IO.puts("Found #{length(resources)} resources")
  94.  
  95. response_xml = build_multistatus_response(resources, requested_props, conn)
  96.  
  97. conn
  98. |> put_resp_content_type("application/xml; charset=utf-8")
  99. |> put_resp_header("dav", "1, 2")
  100. |> send_resp(207, response_xml)
  101. end
  102.  
  103. defp send_file_range(conn, path, file_size, range_header) do
  104. case parse_range_header(range_header, file_size) do
  105. {:ok, start, stop} ->
  106. length = stop - start + 1
  107. IO.puts("Range request: bytes #{start}-#{stop}/#{file_size}")
  108.  
  109. conn
  110. |> put_resp_header("content-range", "bytes #{start}-#{stop}/#{file_size}")
  111. |> put_resp_header("content-length", to_string(length))
  112. |> send_file(206, path, start, length)
  113.  
  114. :error ->
  115. conn
  116. |> put_resp_header("content-range", "bytes */#{file_size}")
  117. |> send_resp(416, "Range Not Satisfiable")
  118. end
  119. end
  120.  
  121. defp parse_range_header(header, file_size) do
  122. case Regex.run(~r/bytes=(\d+)-(\d*)/, header) do
  123. [_, start_str, ""] ->
  124. start = String.to_integer(start_str)
  125. if start < file_size do
  126. {:ok, start, file_size - 1}
  127. else
  128. :error
  129. end
  130.  
  131. [_, start_str, stop_str] ->
  132. start = String.to_integer(start_str)
  133. stop = String.to_integer(stop_str)
  134.  
  135. if start <= stop && start < file_size do
  136. {:ok, start, min(stop, file_size - 1)}
  137. else
  138. :error
  139. end
  140.  
  141. _ ->
  142. :error
  143. end
  144. end
  145.  
  146. defp get_content_type(path) do
  147. case Path.extname(path) |> String.downcase() do
  148. ".mkv" -> "video/x-matroska"
  149. ".mp4" -> "video/mp4"
  150. ".avi" -> "video/x-msvideo"
  151. ".webm" -> "video/webm"
  152. ".mp3" -> "audio/mpeg"
  153. ".flac" -> "audio/flac"
  154. ".jpg" -> "image/jpeg"
  155. ".jpeg" -> "image/jpeg"
  156. ".png" -> "image/png"
  157. ".txt" -> "text/plain"
  158. ".srt" -> "text/plain"
  159. ".vtt" -> "text/vtt"
  160. _ -> "application/octet-stream"
  161. end
  162. end
  163.  
  164. defp map_to_filesystem(url_path) do
  165. # URL decode the path to handle spaces and special characters
  166. decoded_path = URI.decode(url_path)
  167. relative_path = String.trim_leading(decoded_path, "/")
  168. full_path = Path.join(document_root(), relative_path)
  169. IO.puts(" Mapping: #{url_path} -> #{full_path}")
  170. full_path
  171. end
  172.  
  173. defp get_depth_header(conn) do
  174. case Plug.Conn.get_req_header(conn, "depth") do
  175. ["0"] -> 0
  176. ["1"] -> 1
  177. ["infinity"] -> :infinity
  178. _ -> :infinity
  179. end
  180. end
  181.  
  182. defp parse_propfind_body(""), do: :allprop
  183. defp parse_propfind_body(body) do
  184. try do
  185. doc = SweetXml.parse(body)
  186.  
  187. cond do
  188. xpath(doc, ~x"//propfind/allprop"e) != nil ->
  189. :allprop
  190.  
  191. xpath(doc, ~x"//propfind/propname"e) != nil ->
  192. :propname
  193.  
  194. true ->
  195. # Parse the requested properties - simpler approach
  196. case xpath(doc, ~x"//propfind/prop/*"el) do
  197. nil -> :allprop
  198. [] -> :allprop
  199. props ->
  200. Enum.map(props, fn elem ->
  201. # Get element name without namespace
  202. {:xmlElement, name, _, _, _, _, _, _, _, _, _, _} = elem
  203. {to_string(name), []}
  204. end)
  205. end
  206. end
  207. rescue
  208. e ->
  209. IO.puts("Parse error: #{inspect(e)}")
  210. :allprop
  211. end
  212. end
  213.  
  214. defp get_resources(url_path, depth) do
  215. real_path = map_to_filesystem(url_path)
  216.  
  217. case File.stat(real_path) do
  218. {:ok, stat} ->
  219. resources = [{url_path, real_path, stat}]
  220.  
  221. if stat.type == :directory && depth != 0 do
  222. children = list_directory_contents(real_path, url_path, depth)
  223. resources ++ children
  224. else
  225. resources
  226. end
  227.  
  228. {:error, reason} ->
  229. IO.puts(" Failed to stat #{real_path}: #{inspect(reason)}")
  230. []
  231. end
  232. end
  233.  
  234. defp list_directory_contents(dir_path, url_path, depth) do
  235. case File.ls(dir_path) do
  236. {:ok, entries} ->
  237. entries
  238. |> Enum.reject(&String.starts_with?(&1, "._"))
  239. |> Enum.flat_map(fn entry ->
  240. child_real_path = Path.join(dir_path, entry)
  241. # URL encode the entry name for the URL path
  242. encoded_entry = URI.encode(entry, &URI.char_unreserved?/1)
  243. child_url_path =
  244. if url_path == "/" do
  245. "/#{encoded_entry}"
  246. else
  247. "#{url_path}/#{encoded_entry}"
  248. end
  249.  
  250. case File.stat(child_real_path) do
  251. {:ok, stat} ->
  252. if stat.type == :directory && depth == :infinity do
  253. [{child_url_path, child_real_path, stat} |
  254. list_directory_contents(child_real_path, child_url_path, depth)]
  255. else
  256. [{child_url_path, child_real_path, stat}]
  257. end
  258.  
  259. _ ->
  260. []
  261. end
  262. end)
  263.  
  264. {:error, reason} ->
  265. IO.puts(" Failed to list directory #{dir_path}: #{inspect(reason)}")
  266. []
  267. end
  268. end
  269.  
  270. defp build_multistatus_response(resources, requested_props, conn) do
  271. responses = Enum.map(resources, fn {url_path, real_path, stat} ->
  272. build_response_element(url_path, real_path, stat, requested_props, conn)
  273. end)
  274.  
  275. Xml.element("D:multistatus", %{"xmlns:D" => "DAV:"}, responses)
  276. |> Xml.generate()
  277. end
  278.  
  279. defp build_response_element(url_path, real_path, stat, requested_props, conn) do
  280. href = build_href(url_path, stat)
  281. props = build_properties(real_path, stat, requested_props)
  282.  
  283. propstat = Xml.element("D:propstat", [
  284. Xml.element("D:prop", props),
  285. Xml.element("D:status", "HTTP/1.1 200 OK")
  286. ])
  287.  
  288. Xml.element("D:response", [
  289. Xml.element("D:href", href),
  290. propstat
  291. ])
  292. end
  293.  
  294. defp build_href(path, stat) do
  295. # Ensure trailing slash for directories
  296. if stat.type == :directory && !String.ends_with?(path, "/") do
  297. path <> "/"
  298. else
  299. path
  300. end
  301. end
  302.  
  303. defp build_properties(real_path, stat, :allprop) do
  304. base_props = [
  305. Xml.element("D:displayname", Path.basename(real_path)),
  306. Xml.element("D:getlastmodified", format_datetime(stat.mtime)),
  307. Xml.element("D:creationdate", format_datetime(stat.ctime))
  308. ]
  309.  
  310. case stat.type do
  311. :directory ->
  312. base_props ++ [
  313. Xml.element("D:resourcetype", [Xml.element("D:collection")]),
  314. Xml.element("D:iscollection", "1")
  315. ]
  316. :regular ->
  317. base_props ++ [
  318. Xml.element("D:resourcetype", []),
  319. Xml.element("D:getcontentlength", to_string(stat.size)),
  320. Xml.element("D:getcontenttype", get_content_type(real_path)),
  321. Xml.element("D:iscollection", "0")
  322. ]
  323. _ ->
  324. base_props ++ [Xml.element("D:resourcetype", [])]
  325. end
  326. end
  327.  
  328. defp build_properties(_real_path, _stat, :propname) do
  329. [
  330. Xml.element("D:displayname"),
  331. Xml.element("D:getlastmodified"),
  332. Xml.element("D:creationdate"),
  333. Xml.element("D:resourcetype"),
  334. Xml.element("D:getcontentlength"),
  335. Xml.element("D:getcontenttype"),
  336. Xml.element("D:iscollection")
  337. ]
  338. end
  339.  
  340. defp build_properties(real_path, stat, props) when is_list(props) do
  341. Enum.map(props, fn {name, _attrs} ->
  342. case name do
  343. "displayname" ->
  344. Xml.element("D:displayname", Path.basename(real_path))
  345. "getlastmodified" ->
  346. Xml.element("D:getlastmodified", format_datetime(stat.mtime))
  347. "creationdate" ->
  348. Xml.element("D:creationdate", format_datetime(stat.ctime))
  349. "resourcetype" ->
  350. if stat.type == :directory do
  351. Xml.element("D:resourcetype", [Xml.element("D:collection")])
  352. else
  353. Xml.element("D:resourcetype", [])
  354. end
  355. "getcontentlength" when stat.type == :regular ->
  356. Xml.element("D:getcontentlength", to_string(stat.size))
  357. "getcontenttype" when stat.type == :regular ->
  358. Xml.element("D:getcontenttype", get_content_type(real_path))
  359. "iscollection" ->
  360. if stat.type == :directory do
  361. Xml.element("D:iscollection", "1")
  362. else
  363. Xml.element("D:iscollection", "0")
  364. end
  365. _ -> nil
  366. end
  367. end)
  368. |> Enum.filter(&(&1 != nil))
  369. end
  370.  
  371. defp format_datetime({{year, month, day}, {hour, minute, second}}) do
  372. :io_lib.format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
  373. [year, month, day, hour, minute, second])
  374. |> IO.iodata_to_binary()
  375. end
  376. end
  377.  
Advertisement
Add Comment
Please, Sign In to add comment