asyncftpclient.nim 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. #
  2. #
  3. # Nim's Runtime Library
  4. # (c) Copyright 2015 Dominik Picheta
  5. # See the file "copying.txt", included in this
  6. # distribution, for details about the copyright.
  7. #
  8. ## This module implements an asynchronous FTP client. It allows you to connect
  9. ## to an FTP server and perform operations on it such as for example:
  10. ##
  11. ## * The upload of new files.
  12. ## * The removal of existing files.
  13. ## * Download of files.
  14. ## * Changing of files' permissions.
  15. ## * Navigation through the FTP server's directories.
  16. ##
  17. ## Connecting to an FTP server
  18. ## ------------------------
  19. ##
  20. ## In order to begin any sort of transfer of files you must first
  21. ## connect to an FTP server. You can do so with the ``connect`` procedure.
  22. ##
  23. ## .. code-block::nim
  24. ## import asyncdispatch, asyncftpclient
  25. ## proc main() {.async.} =
  26. ## var ftp = newAsyncFtpClient("example.com", user = "test", pass = "test")
  27. ## await ftp.connect()
  28. ## echo("Connected")
  29. ## waitFor(main())
  30. ##
  31. ## A new ``main`` async procedure must be declared to allow the use of the
  32. ## ``await`` keyword. The connection will complete asynchronously and the
  33. ## client will be connected after the ``await ftp.connect()`` call.
  34. ##
  35. ## Uploading a new file
  36. ## --------------------
  37. ##
  38. ## After a connection is made you can use the ``store`` procedure to upload
  39. ## a new file to the FTP server. Make sure to check you are in the correct
  40. ## working directory before you do so with the ``pwd`` procedure, you can also
  41. ## instead specify an absolute path.
  42. ##
  43. ## .. code-block::nim
  44. ## import asyncdispatch, asyncftpclient
  45. ## proc main() {.async.} =
  46. ## var ftp = newAsyncFtpClient("example.com", user = "test", pass = "test")
  47. ## await ftp.connect()
  48. ## let currentDir = await ftp.pwd()
  49. ## assert currentDir == "/home/user/"
  50. ## await ftp.store("file.txt", "file.txt")
  51. ## echo("File finished uploading")
  52. ## waitFor(main())
  53. ##
  54. ## Checking the progress of a file transfer
  55. ## ----------------------------------------
  56. ##
  57. ## The progress of either a file upload or a file download can be checked
  58. ## by specifying a ``onProgressChanged`` procedure to the ``store`` or
  59. ## ``retrFile`` procedures.
  60. ##
  61. ## .. code-block::nim
  62. ## import asyncdispatch, asyncftpclient
  63. ##
  64. ## proc onProgressChanged(total, progress: BiggestInt,
  65. ## speed: float): Future[void] =
  66. ## echo("Uploaded ", progress, " of ", total, " bytes")
  67. ## echo("Current speed: ", speed, " kb/s")
  68. ##
  69. ## proc main() {.async.} =
  70. ## var ftp = newAsyncFtpClient("example.com", user = "test", pass = "test")
  71. ## await ftp.connect()
  72. ## await ftp.store("file.txt", "/home/user/file.txt", onProgressChanged)
  73. ## echo("File finished uploading")
  74. ## waitFor(main())
  75. import asyncdispatch, asyncnet, strutils, parseutils, os, times
  76. from ftpclient import FtpBaseObj, ReplyError, FtpEvent
  77. from net import BufferSize
  78. type
  79. AsyncFtpClientObj* = FtpBaseObj[AsyncSocket]
  80. AsyncFtpClient* = ref AsyncFtpClientObj
  81. ProgressChangedProc* =
  82. proc (total, progress: BiggestInt, speed: float):
  83. Future[void] {.closure, gcsafe.}
  84. const multiLineLimit = 10000
  85. proc expectReply(ftp: AsyncFtpClient): Future[TaintedString] {.async.} =
  86. result = await ftp.csock.recvLine()
  87. var count = 0
  88. while result[3] == '-':
  89. ## Multi-line reply.
  90. let line = await ftp.csock.recvLine()
  91. result.add("\n" & line)
  92. count.inc()
  93. if count >= multiLineLimit:
  94. raise newException(ReplyError, "Reached maximum multi-line reply count.")
  95. proc send*(ftp: AsyncFtpClient, m: string): Future[TaintedString] {.async.} =
  96. ## Send a message to the server, and wait for a primary reply.
  97. ## ``\c\L`` is added for you.
  98. ##
  99. ## **Note:** The server may return multiple lines of coded replies.
  100. await ftp.csock.send(m & "\c\L")
  101. return await ftp.expectReply()
  102. proc assertReply(received: TaintedString, expected: varargs[string]) =
  103. for i in items(expected):
  104. if received.string.startsWith(i): return
  105. raise newException(ReplyError,
  106. "Expected reply '$1' got: $2" %
  107. [expected.join("' or '"), received.string])
  108. proc pasv(ftp: AsyncFtpClient) {.async.} =
  109. ## Negotiate a data connection.
  110. ftp.dsock = newAsyncSocket()
  111. var pasvMsg = (await ftp.send("PASV")).string.strip.TaintedString
  112. assertReply(pasvMsg, "227")
  113. var betweenParens = captureBetween(pasvMsg.string, '(', ')')
  114. var nums = betweenParens.split(',')
  115. var ip = nums[0.. ^3]
  116. var port = nums[^2.. ^1]
  117. var properPort = port[0].parseInt()*256+port[1].parseInt()
  118. await ftp.dsock.connect(ip.join("."), Port(properPort.toU16))
  119. ftp.dsockConnected = true
  120. proc normalizePathSep(path: string): string =
  121. return replace(path, '\\', '/')
  122. proc connect*(ftp: AsyncFtpClient) {.async.} =
  123. ## Connect to the FTP server specified by ``ftp``.
  124. await ftp.csock.connect(ftp.address, ftp.port)
  125. var reply = await ftp.expectReply()
  126. if reply.startsWith("120"):
  127. # 120 Service ready in nnn minutes.
  128. # We wait until we receive 220.
  129. reply = await ftp.expectReply()
  130. # Handle 220 messages from the server
  131. assertReply(reply, "220")
  132. if ftp.user != "":
  133. assertReply(await(ftp.send("USER " & ftp.user)), "230", "331")
  134. if ftp.pass != "":
  135. assertReply(await(ftp.send("PASS " & ftp.pass)), "230")
  136. proc pwd*(ftp: AsyncFtpClient): Future[TaintedString] {.async.} =
  137. ## Returns the current working directory.
  138. let wd = await ftp.send("PWD")
  139. assertReply wd, "257"
  140. return wd.string.captureBetween('"').TaintedString # "
  141. proc cd*(ftp: AsyncFtpClient, dir: string) {.async.} =
  142. ## Changes the current directory on the remote FTP server to ``dir``.
  143. assertReply(await(ftp.send("CWD " & dir.normalizePathSep)), "250")
  144. proc cdup*(ftp: AsyncFtpClient) {.async.} =
  145. ## Changes the current directory to the parent of the current directory.
  146. assertReply(await(ftp.send("CDUP")), "200")
  147. proc getLines(ftp: AsyncFtpClient): Future[string] {.async.} =
  148. ## Downloads text data in ASCII mode
  149. result = ""
  150. assert ftp.dsockConnected
  151. while ftp.dsockConnected:
  152. let r = await ftp.dsock.recvLine()
  153. if r.string == "":
  154. ftp.dsockConnected = false
  155. else:
  156. result.add(r.string & "\n")
  157. assertReply(await(ftp.expectReply()), "226")
  158. proc listDirs*(ftp: AsyncFtpClient, dir = ""): Future[seq[string]] {.async.} =
  159. ## Returns a list of filenames in the given directory. If ``dir`` is "",
  160. ## the current directory is used. If ``async`` is true, this
  161. ## function will return immediately and it will be your job to
  162. ## use asyncio's ``poll`` to progress this operation.
  163. await ftp.pasv()
  164. assertReply(await(ftp.send("NLST " & dir.normalizePathSep)), ["125", "150"])
  165. result = splitLines(await ftp.getLines())
  166. proc existsFile*(ftp: AsyncFtpClient, file: string): Future[bool] {.async.} =
  167. ## Determines whether ``file`` exists.
  168. var files = await ftp.listDirs()
  169. for f in items(files):
  170. if f.normalizePathSep == file.normalizePathSep: return true
  171. proc createDir*(ftp: AsyncFtpClient, dir: string, recursive = false){.async.} =
  172. ## Creates a directory ``dir``. If ``recursive`` is true, the topmost
  173. ## subdirectory of ``dir`` will be created first, following the secondmost...
  174. ## etc. this allows you to give a full path as the ``dir`` without worrying
  175. ## about subdirectories not existing.
  176. if not recursive:
  177. assertReply(await(ftp.send("MKD " & dir.normalizePathSep)), "257")
  178. else:
  179. var reply = TaintedString""
  180. var previousDirs = ""
  181. for p in split(dir, {os.DirSep, os.AltSep}):
  182. if p != "":
  183. previousDirs.add(p)
  184. reply = await ftp.send("MKD " & previousDirs)
  185. previousDirs.add('/')
  186. assertReply reply, "257"
  187. proc chmod*(ftp: AsyncFtpClient, path: string,
  188. permissions: set[FilePermission]) {.async.} =
  189. ## Changes permission of ``path`` to ``permissions``.
  190. var userOctal = 0
  191. var groupOctal = 0
  192. var otherOctal = 0
  193. for i in items(permissions):
  194. case i
  195. of fpUserExec: userOctal.inc(1)
  196. of fpUserWrite: userOctal.inc(2)
  197. of fpUserRead: userOctal.inc(4)
  198. of fpGroupExec: groupOctal.inc(1)
  199. of fpGroupWrite: groupOctal.inc(2)
  200. of fpGroupRead: groupOctal.inc(4)
  201. of fpOthersExec: otherOctal.inc(1)
  202. of fpOthersWrite: otherOctal.inc(2)
  203. of fpOthersRead: otherOctal.inc(4)
  204. var perm = $userOctal & $groupOctal & $otherOctal
  205. assertReply(await(ftp.send("SITE CHMOD " & perm &
  206. " " & path.normalizePathSep)), "200")
  207. proc list*(ftp: AsyncFtpClient, dir = ""): Future[string] {.async.} =
  208. ## Lists all files in ``dir``. If ``dir`` is ``""``, uses the current
  209. ## working directory.
  210. await ftp.pasv()
  211. let reply = await ftp.send("LIST" & " " & dir.normalizePathSep)
  212. assertReply(reply, ["125", "150"])
  213. result = await ftp.getLines()
  214. proc retrText*(ftp: AsyncFtpClient, file: string): Future[string] {.async.} =
  215. ## Retrieves ``file``. File must be ASCII text.
  216. await ftp.pasv()
  217. let reply = await ftp.send("RETR " & file.normalizePathSep)
  218. assertReply(reply, ["125", "150"])
  219. result = await ftp.getLines()
  220. proc getFile(ftp: AsyncFtpClient, file: File, total: BiggestInt,
  221. onProgressChanged: ProgressChangedProc) {.async.} =
  222. assert ftp.dsockConnected
  223. var progress = 0
  224. var progressInSecond = 0
  225. var countdownFut = sleepAsync(1000)
  226. var dataFut = ftp.dsock.recv(BufferSize)
  227. while ftp.dsockConnected:
  228. await dataFut or countdownFut
  229. if countdownFut.finished:
  230. asyncCheck onProgressChanged(total, progress,
  231. progressInSecond.float)
  232. progressInSecond = 0
  233. countdownFut = sleepAsync(1000)
  234. if dataFut.finished:
  235. let data = dataFut.read
  236. if data != "":
  237. progress.inc(data.len)
  238. progressInSecond.inc(data.len)
  239. file.write(data)
  240. dataFut = ftp.dsock.recv(BufferSize)
  241. else:
  242. ftp.dsockConnected = false
  243. assertReply(await(ftp.expectReply()), "226")
  244. proc defaultOnProgressChanged*(total, progress: BiggestInt,
  245. speed: float): Future[void] {.nimcall,gcsafe,procvar.} =
  246. ## Default FTP ``onProgressChanged`` handler. Does nothing.
  247. result = newFuture[void]()
  248. #echo(total, " ", progress, " ", speed)
  249. result.complete()
  250. proc retrFile*(ftp: AsyncFtpClient, file, dest: string,
  251. onProgressChanged: ProgressChangedProc = defaultOnProgressChanged) {.async.} =
  252. ## Downloads ``file`` and saves it to ``dest``.
  253. ## The ``EvRetr`` event is passed to the specified ``handleEvent`` function
  254. ## when the download is finished. The event's ``filename`` field will be equal
  255. ## to ``file``.
  256. var destFile = open(dest, mode = fmWrite)
  257. await ftp.pasv()
  258. var reply = await ftp.send("RETR " & file.normalizePathSep)
  259. assertReply reply, ["125", "150"]
  260. if {'(', ')'} notin reply.string:
  261. raise newException(ReplyError, "Reply has no file size.")
  262. var fileSize: BiggestInt
  263. if reply.string.captureBetween('(', ')').parseBiggestInt(fileSize) == 0:
  264. raise newException(ReplyError, "Reply has no file size.")
  265. await getFile(ftp, destFile, fileSize, onProgressChanged)
  266. destFile.close()
  267. proc doUpload(ftp: AsyncFtpClient, file: File,
  268. onProgressChanged: ProgressChangedProc) {.async.} =
  269. assert ftp.dsockConnected
  270. let total = file.getFileSize()
  271. var data = newStringOfCap(4000)
  272. var progress = 0
  273. var progressInSecond = 0
  274. var countdownFut = sleepAsync(1000)
  275. var sendFut: Future[void] = nil
  276. while ftp.dsockConnected:
  277. if sendFut == nil or sendFut.finished:
  278. progress.inc(data.len)
  279. progressInSecond.inc(data.len)
  280. # TODO: Async file reading.
  281. let len = file.readBuffer(addr(data[0]), 4000)
  282. setLen(data, len)
  283. if len == 0:
  284. # File finished uploading.
  285. ftp.dsock.close()
  286. ftp.dsockConnected = false
  287. assertReply(await(ftp.expectReply()), "226")
  288. else:
  289. sendFut = ftp.dsock.send(data)
  290. if countdownFut.finished:
  291. asyncCheck onProgressChanged(total, progress, progressInSecond.float)
  292. progressInSecond = 0
  293. countdownFut = sleepAsync(1000)
  294. await countdownFut or sendFut
  295. proc store*(ftp: AsyncFtpClient, file, dest: string,
  296. onProgressChanged: ProgressChangedProc = defaultOnProgressChanged) {.async.} =
  297. ## Uploads ``file`` to ``dest`` on the remote FTP server. Usage of this
  298. ## function asynchronously is recommended to view the progress of
  299. ## the download.
  300. ## The ``EvStore`` event is passed to the specified ``handleEvent`` function
  301. ## when the upload is finished, and the ``filename`` field will be
  302. ## equal to ``file``.
  303. var destFile = open(file)
  304. await ftp.pasv()
  305. let reply = await ftp.send("STOR " & dest.normalizePathSep)
  306. assertReply reply, ["125", "150"]
  307. await doUpload(ftp, destFile, onProgressChanged)
  308. proc rename*(ftp: AsyncFtpClient, nameFrom: string, nameTo: string) {.async.} =
  309. ## Rename a file or directory on the remote FTP Server from current name
  310. ## ``name_from`` to new name ``name_to``
  311. assertReply(await ftp.send("RNFR " & name_from), "350")
  312. assertReply(await ftp.send("RNTO " & name_to), "250")
  313. proc removeFile*(ftp: AsyncFtpClient, filename: string) {.async.} =
  314. ## Delete a file ``filename`` on the remote FTP server
  315. assertReply(await ftp.send("DELE " & filename), "250")
  316. proc removeDir*(ftp: AsyncFtpClient, dir: string) {.async.} =
  317. ## Delete a directory ``dir`` on the remote FTP server
  318. assertReply(await ftp.send("RMD " & dir), "250")
  319. proc newAsyncFtpClient*(address: string, port = Port(21),
  320. user, pass = ""): AsyncFtpClient =
  321. ## Creates a new ``AsyncFtpClient`` object.
  322. new result
  323. result.user = user
  324. result.pass = pass
  325. result.address = address
  326. result.port = port
  327. result.dsockConnected = false
  328. result.csock = newAsyncSocket()
  329. when not defined(testing) and isMainModule:
  330. var ftp = newAsyncFtpClient("example.com", user = "test", pass = "test")
  331. proc main(ftp: AsyncFtpClient) {.async.} =
  332. await ftp.connect()
  333. echo await ftp.pwd()
  334. echo await ftp.listDirs()
  335. await ftp.store("payload.jpg", "payload.jpg")
  336. await ftp.retrFile("payload.jpg", "payload2.jpg")
  337. await ftp.rename("payload.jpg", "payload_renamed.jpg")
  338. await ftp.store("payload.jpg", "payload_remove.jpg")
  339. await ftp.removeFile("payload_remove.jpg")
  340. await ftp.createDir("deleteme")
  341. await ftp.removeDir("deleteme")
  342. echo("Finished")
  343. waitFor main(ftp)