asyncftpclient.nim 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  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, nativesockets, strutils, parseutils, os, times
  76. from net import BufferSize
  77. type
  78. AsyncFtpClient* = ref object
  79. csock*: AsyncSocket
  80. dsock*: AsyncSocket
  81. user*, pass*: string
  82. address*: string
  83. port*: Port
  84. jobInProgress*: bool
  85. job*: FtpJob
  86. dsockConnected*: bool
  87. FtpJobType* = enum
  88. JRetrText, JRetr, JStore
  89. FtpJob = ref object
  90. prc: proc (ftp: AsyncFtpClient, async: bool): bool {.nimcall, gcsafe.}
  91. case typ*: FtpJobType
  92. of JRetrText:
  93. lines: string
  94. of JRetr, JStore:
  95. file: File
  96. filename: string
  97. total: BiggestInt # In bytes.
  98. progress: BiggestInt # In bytes.
  99. oneSecond: BiggestInt # Bytes transferred in one second.
  100. lastProgressReport: float # Time
  101. toStore: string # Data left to upload (Only used with async)
  102. FtpEventType* = enum
  103. EvTransferProgress, EvLines, EvRetr, EvStore
  104. FtpEvent* = object ## Event
  105. filename*: string
  106. case typ*: FtpEventType
  107. of EvLines:
  108. lines*: string ## Lines that have been transferred.
  109. of EvRetr, EvStore: ## Retr/Store operation finished.
  110. nil
  111. of EvTransferProgress:
  112. bytesTotal*: BiggestInt ## Bytes total.
  113. bytesFinished*: BiggestInt ## Bytes transferred.
  114. speed*: BiggestInt ## Speed in bytes/s
  115. currentJob*: FtpJobType ## The current job being performed.
  116. ReplyError* = object of IOError
  117. ProgressChangedProc* =
  118. proc (total, progress: BiggestInt, speed: float):
  119. Future[void] {.closure, gcsafe.}
  120. const multiLineLimit = 10000
  121. proc expectReply(ftp: AsyncFtpClient): Future[TaintedString] {.async.} =
  122. var line = await ftp.csock.recvLine()
  123. result = TaintedString(line)
  124. var count = 0
  125. while line.len > 3 and line[3] == '-':
  126. ## Multi-line reply.
  127. line = await ftp.csock.recvLine()
  128. string(result).add("\n" & line)
  129. count.inc()
  130. if count >= multiLineLimit:
  131. raise newException(ReplyError, "Reached maximum multi-line reply count.")
  132. proc send*(ftp: AsyncFtpClient, m: string): Future[TaintedString] {.async.} =
  133. ## Send a message to the server, and wait for a primary reply.
  134. ## ``\c\L`` is added for you.
  135. ##
  136. ## You need to make sure that the message ``m`` doesn't contain any newline
  137. ## characters. Failing to do so will raise ``AssertionDefect``.
  138. ##
  139. ## **Note:** The server may return multiple lines of coded replies.
  140. doAssert(not m.contains({'\c', '\L'}), "message shouldn't contain any newline characters")
  141. await ftp.csock.send(m & "\c\L")
  142. return await ftp.expectReply()
  143. proc assertReply(received: TaintedString, expected: varargs[string]) =
  144. for i in items(expected):
  145. if received.string.startsWith(i): return
  146. raise newException(ReplyError,
  147. "Expected reply '$1' got: $2" %
  148. [expected.join("' or '"), received.string])
  149. proc pasv(ftp: AsyncFtpClient) {.async.} =
  150. ## Negotiate a data connection.
  151. ftp.dsock = newAsyncSocket()
  152. var pasvMsg = (await ftp.send("PASV")).string.strip.TaintedString
  153. assertReply(pasvMsg, "227")
  154. var betweenParens = captureBetween(pasvMsg.string, '(', ')')
  155. var nums = betweenParens.split(',')
  156. var ip = nums[0 .. ^3]
  157. var port = nums[^2 .. ^1]
  158. var properPort = port[0].parseInt()*256+port[1].parseInt()
  159. await ftp.dsock.connect(ip.join("."), Port(properPort))
  160. ftp.dsockConnected = true
  161. proc normalizePathSep(path: string): string =
  162. return replace(path, '\\', '/')
  163. proc connect*(ftp: AsyncFtpClient) {.async.} =
  164. ## Connect to the FTP server specified by ``ftp``.
  165. await ftp.csock.connect(ftp.address, ftp.port)
  166. var reply = await ftp.expectReply()
  167. if string(reply).startsWith("120"):
  168. # 120 Service ready in nnn minutes.
  169. # We wait until we receive 220.
  170. reply = await ftp.expectReply()
  171. # Handle 220 messages from the server
  172. assertReply(reply, "220")
  173. if ftp.user != "":
  174. assertReply(await(ftp.send("USER " & ftp.user)), "230", "331")
  175. if ftp.pass != "":
  176. assertReply(await(ftp.send("PASS " & ftp.pass)), "230")
  177. proc pwd*(ftp: AsyncFtpClient): Future[TaintedString] {.async.} =
  178. ## Returns the current working directory.
  179. let wd = await ftp.send("PWD")
  180. assertReply wd, "257"
  181. return wd.string.captureBetween('"').TaintedString # "
  182. proc cd*(ftp: AsyncFtpClient, dir: string) {.async.} =
  183. ## Changes the current directory on the remote FTP server to ``dir``.
  184. assertReply(await(ftp.send("CWD " & dir.normalizePathSep)), "250")
  185. proc cdup*(ftp: AsyncFtpClient) {.async.} =
  186. ## Changes the current directory to the parent of the current directory.
  187. assertReply(await(ftp.send("CDUP")), "200")
  188. proc getLines(ftp: AsyncFtpClient): Future[string] {.async.} =
  189. ## Downloads text data in ASCII mode
  190. result = ""
  191. assert ftp.dsockConnected
  192. while ftp.dsockConnected:
  193. let r = await ftp.dsock.recvLine()
  194. if r.string == "":
  195. ftp.dsockConnected = false
  196. else:
  197. result.add(r.string & "\n")
  198. assertReply(await(ftp.expectReply()), "226")
  199. proc listDirs*(ftp: AsyncFtpClient, dir = ""): Future[seq[string]] {.async.} =
  200. ## Returns a list of filenames in the given directory. If ``dir`` is "",
  201. ## the current directory is used. If ``async`` is true, this
  202. ## function will return immediately and it will be your job to
  203. ## use asyncdispatch's ``poll`` to progress this operation.
  204. await ftp.pasv()
  205. assertReply(await(ftp.send("NLST " & dir.normalizePathSep)), ["125", "150"])
  206. result = splitLines(await ftp.getLines())
  207. proc existsFile*(ftp: AsyncFtpClient, file: string): Future[bool] {.async.} =
  208. ## Determines whether ``file`` exists.
  209. var files = await ftp.listDirs()
  210. for f in items(files):
  211. if f.normalizePathSep == file.normalizePathSep: return true
  212. proc createDir*(ftp: AsyncFtpClient, dir: string, recursive = false){.async.} =
  213. ## Creates a directory ``dir``. If ``recursive`` is true, the topmost
  214. ## subdirectory of ``dir`` will be created first, following the secondmost...
  215. ## etc. this allows you to give a full path as the ``dir`` without worrying
  216. ## about subdirectories not existing.
  217. if not recursive:
  218. assertReply(await(ftp.send("MKD " & dir.normalizePathSep)), "257")
  219. else:
  220. var reply = TaintedString""
  221. var previousDirs = ""
  222. for p in split(dir, {os.DirSep, os.AltSep}):
  223. if p != "":
  224. previousDirs.add(p)
  225. reply = await ftp.send("MKD " & previousDirs)
  226. previousDirs.add('/')
  227. assertReply reply, "257"
  228. proc chmod*(ftp: AsyncFtpClient, path: string,
  229. permissions: set[FilePermission]) {.async.} =
  230. ## Changes permission of ``path`` to ``permissions``.
  231. var userOctal = 0
  232. var groupOctal = 0
  233. var otherOctal = 0
  234. for i in items(permissions):
  235. case i
  236. of fpUserExec: userOctal.inc(1)
  237. of fpUserWrite: userOctal.inc(2)
  238. of fpUserRead: userOctal.inc(4)
  239. of fpGroupExec: groupOctal.inc(1)
  240. of fpGroupWrite: groupOctal.inc(2)
  241. of fpGroupRead: groupOctal.inc(4)
  242. of fpOthersExec: otherOctal.inc(1)
  243. of fpOthersWrite: otherOctal.inc(2)
  244. of fpOthersRead: otherOctal.inc(4)
  245. var perm = $userOctal & $groupOctal & $otherOctal
  246. assertReply(await(ftp.send("SITE CHMOD " & perm &
  247. " " & path.normalizePathSep)), "200")
  248. proc list*(ftp: AsyncFtpClient, dir = ""): Future[string] {.async.} =
  249. ## Lists all files in ``dir``. If ``dir`` is ``""``, uses the current
  250. ## working directory.
  251. await ftp.pasv()
  252. let reply = await ftp.send("LIST" & " " & dir.normalizePathSep)
  253. assertReply(reply, ["125", "150"])
  254. result = await ftp.getLines()
  255. proc retrText*(ftp: AsyncFtpClient, file: string): Future[string] {.async.} =
  256. ## Retrieves ``file``. File must be ASCII text.
  257. await ftp.pasv()
  258. let reply = await ftp.send("RETR " & file.normalizePathSep)
  259. assertReply(reply, ["125", "150"])
  260. result = await ftp.getLines()
  261. proc getFile(ftp: AsyncFtpClient, file: File, total: BiggestInt,
  262. onProgressChanged: ProgressChangedProc) {.async.} =
  263. assert ftp.dsockConnected
  264. var progress = 0
  265. var progressInSecond = 0
  266. var countdownFut = sleepAsync(1000)
  267. var dataFut = ftp.dsock.recv(BufferSize)
  268. while ftp.dsockConnected:
  269. await dataFut or countdownFut
  270. if countdownFut.finished:
  271. asyncCheck onProgressChanged(total, progress,
  272. progressInSecond.float)
  273. progressInSecond = 0
  274. countdownFut = sleepAsync(1000)
  275. if dataFut.finished:
  276. let data = dataFut.read
  277. if data != "":
  278. progress.inc(data.len)
  279. progressInSecond.inc(data.len)
  280. file.write(data)
  281. dataFut = ftp.dsock.recv(BufferSize)
  282. else:
  283. ftp.dsockConnected = false
  284. assertReply(await(ftp.expectReply()), "226")
  285. proc defaultOnProgressChanged*(total, progress: BiggestInt,
  286. speed: float): Future[void] {.nimcall, gcsafe, procvar.} =
  287. ## Default FTP ``onProgressChanged`` handler. Does nothing.
  288. result = newFuture[void]()
  289. #echo(total, " ", progress, " ", speed)
  290. result.complete()
  291. proc retrFile*(ftp: AsyncFtpClient, file, dest: string,
  292. onProgressChanged: ProgressChangedProc = defaultOnProgressChanged) {.async.} =
  293. ## Downloads ``file`` and saves it to ``dest``.
  294. ## The ``EvRetr`` event is passed to the specified ``handleEvent`` function
  295. ## when the download is finished. The event's ``filename`` field will be equal
  296. ## to ``file``.
  297. var destFile = open(dest, mode = fmWrite)
  298. await ftp.pasv()
  299. var reply = await ftp.send("RETR " & file.normalizePathSep)
  300. assertReply reply, ["125", "150"]
  301. if {'(', ')'} notin reply.string:
  302. raise newException(ReplyError, "Reply has no file size.")
  303. var fileSize: BiggestInt
  304. if reply.string.captureBetween('(', ')').parseBiggestInt(fileSize) == 0:
  305. raise newException(ReplyError, "Reply has no file size.")
  306. await getFile(ftp, destFile, fileSize, onProgressChanged)
  307. destFile.close()
  308. proc doUpload(ftp: AsyncFtpClient, file: File,
  309. onProgressChanged: ProgressChangedProc) {.async.} =
  310. assert ftp.dsockConnected
  311. let total = file.getFileSize()
  312. var data = newString(4000)
  313. var progress = 0
  314. var progressInSecond = 0
  315. var countdownFut = sleepAsync(1000)
  316. var sendFut: Future[void] = nil
  317. while ftp.dsockConnected:
  318. if sendFut == nil or sendFut.finished:
  319. # TODO: Async file reading.
  320. let len = file.readBuffer(addr(data[0]), 4000)
  321. setLen(data, len)
  322. if len == 0:
  323. # File finished uploading.
  324. ftp.dsock.close()
  325. ftp.dsockConnected = false
  326. assertReply(await(ftp.expectReply()), "226")
  327. else:
  328. progress.inc(len)
  329. progressInSecond.inc(len)
  330. sendFut = ftp.dsock.send(data)
  331. if countdownFut.finished:
  332. asyncCheck onProgressChanged(total, progress, progressInSecond.float)
  333. progressInSecond = 0
  334. countdownFut = sleepAsync(1000)
  335. await countdownFut or sendFut
  336. proc store*(ftp: AsyncFtpClient, file, dest: string,
  337. onProgressChanged: ProgressChangedProc = defaultOnProgressChanged) {.async.} =
  338. ## Uploads ``file`` to ``dest`` on the remote FTP server. Usage of this
  339. ## function asynchronously is recommended to view the progress of
  340. ## the download.
  341. ## The ``EvStore`` event is passed to the specified ``handleEvent`` function
  342. ## when the upload is finished, and the ``filename`` field will be
  343. ## equal to ``file``.
  344. var destFile = open(file)
  345. await ftp.pasv()
  346. let reply = await ftp.send("STOR " & dest.normalizePathSep)
  347. assertReply reply, ["125", "150"]
  348. await doUpload(ftp, destFile, onProgressChanged)
  349. proc rename*(ftp: AsyncFtpClient, nameFrom: string, nameTo: string) {.async.} =
  350. ## Rename a file or directory on the remote FTP Server from current name
  351. ## ``name_from`` to new name ``name_to``
  352. assertReply(await ftp.send("RNFR " & nameFrom), "350")
  353. assertReply(await ftp.send("RNTO " & nameTo), "250")
  354. proc removeFile*(ftp: AsyncFtpClient, filename: string) {.async.} =
  355. ## Delete a file ``filename`` on the remote FTP server
  356. assertReply(await ftp.send("DELE " & filename), "250")
  357. proc removeDir*(ftp: AsyncFtpClient, dir: string) {.async.} =
  358. ## Delete a directory ``dir`` on the remote FTP server
  359. assertReply(await ftp.send("RMD " & dir), "250")
  360. proc newAsyncFtpClient*(address: string, port = Port(21),
  361. user, pass = ""): AsyncFtpClient =
  362. ## Creates a new ``AsyncFtpClient`` object.
  363. new result
  364. result.user = user
  365. result.pass = pass
  366. result.address = address
  367. result.port = port
  368. result.dsockConnected = false
  369. result.csock = newAsyncSocket()
  370. when not defined(testing) and isMainModule:
  371. var ftp = newAsyncFtpClient("example.com", user = "test", pass = "test")
  372. proc main(ftp: AsyncFtpClient) {.async.} =
  373. await ftp.connect()
  374. echo await ftp.pwd()
  375. echo await ftp.listDirs()
  376. await ftp.store("payload.jpg", "payload.jpg")
  377. await ftp.retrFile("payload.jpg", "payload2.jpg")
  378. await ftp.rename("payload.jpg", "payload_renamed.jpg")
  379. await ftp.store("payload.jpg", "payload_remove.jpg")
  380. await ftp.removeFile("payload_remove.jpg")
  381. await ftp.createDir("deleteme")
  382. await ftp.removeDir("deleteme")
  383. echo("Finished")
  384. waitFor main(ftp)