asyncftpclient.nim 16 KB

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