summary refs log tree commit diff stats
path: root/lib/pure/ftpclient.nim
diff options
context:
space:
mode:
Diffstat (limited to 'lib/pure/ftpclient.nim')
-rw-r--r--lib/pure/ftpclient.nim359
1 files changed, 359 insertions, 0 deletions
diff --git a/lib/pure/ftpclient.nim b/lib/pure/ftpclient.nim
new file mode 100644
index 000000000..0c4ec7a59
--- /dev/null
+++ b/lib/pure/ftpclient.nim
@@ -0,0 +1,359 @@
+import sockets, strutils, parseutils, times
+
+## This module **partially** implements an FTP client as specified
+## by `RFC 959 <http://tools.ietf.org/html/rfc959>`_. 
+## Functions which require file transfers have an ``async`` parameter, when
+## this parameter is set to ``true``, it is your job to call the ``poll`` 
+## function periodically to progress the transfer.
+##
+## Here is some example usage of this module:
+## 
+## .. code-block:: Nimrod
+##    var ftp = FTPClient("example.org", user = "user", pass = "pass")
+##    ftp.connect()
+##    ftp.retrFile("file.ext", "file.ext", async = true)
+##    while True:
+##      var event: TFTPEvent
+##      if ftp.poll(event):
+##        case event.typ
+##        of EvRetr:
+##          echo("Download finished!")
+##          break
+##        of EvTransferProgress:
+##          echo(event.speed div 1000, " kb/s")
+##        else: assert(false)
+
+
+type
+  TFTPClient* = object
+    csock: TSocket # Command connection socket
+    dsock: TSocket # Data connection socket
+    user, pass: string
+    address: string
+    port: TPort
+    
+    jobInProgress: bool
+    job: ref TFTPJob
+
+  FTPJobType = enum
+    JListCmd, JRetrText, JRetr, JStore
+
+  TFTPJob = object
+    prc: proc (ftp: var TFTPClient, timeout: int): bool
+    case typ*: FTPJobType
+    of JListCmd, JRetrText:
+      lines: string
+    of JRetr, JStore:
+      dsockClosed: bool
+      file: TFile
+      total: biggestInt # In bytes.
+      progress: biggestInt # In bytes.
+      oneSecond: biggestInt # Bytes transferred in one second.
+      lastProgressReport: float # Time
+    else: nil
+
+  FTPEventType* = enum
+    EvTransferProgress, EvLines, EvRetr, EvStore
+
+  TFTPEvent* = object ## Event
+    case typ*: FTPEventType
+    of EvLines:
+      lines*: string ## Lines that have been transferred.
+    of EvRetr, EvStore: nil
+    of EvTransferProgress:
+      bytesTotal*: biggestInt     ## Bytes total.
+      bytesFinished*: biggestInt  ## Bytes transferred.
+      speed*: biggestInt          ## Speed in bytes/s
+
+  EInvalidReply* = object of ESynch
+  EFTP* = object of ESynch
+
+proc FTPClient*(address: string, port = TPort(21),
+                user, pass = ""): TFTPClient =
+  ## Create a ``TFTPClient`` object.
+  result.user = user
+  result.pass = pass
+  result.address = address
+  result.port = port
+
+proc expectReply(ftp: var TFTPClient): string =
+  result = ""
+  if not ftp.csock.recvLine(result): setLen(result, 0)
+
+proc send*(ftp: var TFTPClient, m: string): string =
+  ## Send a message to the server, and wait for a primary reply.
+  ## ``\c\L`` is added for you.
+  ftp.csock.send(m & "\c\L")
+  return ftp.expectReply()
+
+proc assertReply(received, expected: string) =
+  if not received.startsWith(expected):
+    raise newException(EInvalidReply,
+                       "Expected reply '$1' got: $2" % [expected, received])
+
+proc assertReply(received: string, expected: openarray[string]) =
+  for i in items(expected):
+    if received.startsWith(i): return
+  raise newException(EInvalidReply,
+                     "Expected reply '$1' got: $2" %
+                     [expected.join("' or '"), received])
+
+proc createJob(ftp: var TFTPClient,
+                 prc: proc (ftp: var TFTPClient, timeout: int): bool,
+                 cmd: FTPJobType) =
+  if ftp.jobInProgress:
+    raise newException(EFTP, "Unable to do two jobs at once.")
+  ftp.jobInProgress = true
+  new(ftp.job)
+  ftp.job.prc = prc
+  ftp.job.typ = cmd
+  case cmd
+  of JListCmd, JRetrText:
+    ftp.job.lines = ""
+  of JRetr, JStore:
+    ftp.job.dsockClosed = false
+
+proc deleteJob(ftp: var TFTPClient) =
+  assert ftp.jobInProgress
+  ftp.jobInProgress = false
+  case ftp.job.typ
+  of JListCmd, JRetrText:
+    ftp.job.lines = ""
+  of JRetr, JStore:
+    ftp.job.file.close()
+
+proc pasv(ftp: var TFTPClient) =
+  ## Negotiate a data connection.
+  var pasvMsg = ftp.send("PASV").strip
+  assertReply(pasvMsg, "227")
+  var betweenParens = captureBetween(pasvMsg, '(', ')')
+  var nums = betweenParens.split(',')
+  var ip = nums[0.. -3]
+  var port = nums[-2.. -1]
+  var properPort = port[0].parseInt()*256+port[1].parseInt()
+  ftp.dsock = socket()
+  ftp.dsock.connect(ip.join("."), TPort(properPort.toU16))
+
+proc connect*(ftp: var TFTPClient) =
+  ## Connect to the FTP server specified by ``ftp``.
+  ftp.csock = socket()
+  ftp.csock.connect(ftp.address, ftp.port)
+
+  # TODO: Handle 120? or let user handle it.
+  assertReply ftp.expectReply(), "220"
+
+  if ftp.user != "":
+    assertReply(ftp.send("USER " & ftp.user), "230", "331")
+
+  if ftp.pass != "":
+    assertReply ftp.send("PASS " & ftp.pass), "230"
+
+proc pwd*(ftp: var TFTPClient): string =
+  ## Returns the current working directory.
+  var wd = ftp.send("PWD")
+  assertReply wd, "257"
+  return wd.captureBetween('"') # "
+
+proc cd*(ftp: var TFTPClient, dir: string) =
+  ## Changes the current directory on the remote FTP server to ``dir``.
+  assertReply ftp.send("CWD " & dir), "250"
+
+proc cdup*(ftp: var TFTPClient) =
+  ## Changes the current directory to the parent of the current directory.
+  assertReply ftp.send("CDUP"), "200"
+
+proc asyncLines(ftp: var TFTPClient, timeout: int): bool =
+  ## Downloads text data in ASCII mode, Asynchronously.
+  ## Returns true if the download is complete.
+  var readSocks: seq[TSocket] = @[ftp.dsock, ftp.csock]
+  if readSocks.select(timeout) != 0:
+    if ftp.dsock notin readSocks:
+      var r = ""
+      if ftp.dsock.recvLine(r):
+        ftp.job.lines.add(r & "\n")
+    if ftp.csock notin readSocks:
+      assertReply ftp.expectReply(), "226"
+      return true
+
+proc list*(ftp: var TFTPClient, dir: string = "", async = false): string =
+  ## Lists all files in ``dir``. If ``dir`` is ``""``, uses the current
+  ## working directory. If ``async`` is true, this function will return
+  ## immediately and it will be your job to call ``poll`` to progress this
+  ## operation.
+  ftp.createJob(asyncLines, JRetrText)
+  ftp.pasv()
+
+  assertReply(ftp.send("LIST" & " " & dir), ["125", "150"])
+
+  if not async:
+    while not ftp.job.prc(ftp, 500): nil
+    result = ftp.job.lines
+    ftp.deleteJob()
+  else:
+    return ""
+
+proc retrText*(ftp: var TFTPClient, file: string, async = false): string =
+  ## Retrieves ``file``. File must be ASCII text.
+  ## If ``async`` is true, this function will return immediately and
+  ## it will be your job to call ``poll`` to progress this operation.
+  ftp.createJob(asyncLines, JRetrText)
+  ftp.pasv()
+  assertReply ftp.send("RETR " & file), ["125", "150"]
+  
+  if not async:
+    while not ftp.job.prc(ftp, 500): nil
+    result = ftp.job.lines
+    ftp.deleteJob()
+  else:
+    return ""
+
+proc asyncFile(ftp: var TFTPClient, timeout: int): bool =
+  var readSocks: seq[TSocket] = @[ftp.dsock, ftp.csock]
+  if readSocks.select(timeout) != 0:
+    if ftp.dsock notin readSocks:
+      var r = ftp.dsock.recv()
+      if r != "":
+        ftp.job.progress.inc(r.len())
+        ftp.job.oneSecond.inc(r.len())
+        ftp.job.file.write(r)
+      
+    if ftp.csock notin readSocks:
+      assertReply ftp.expectReply(), "226"
+      return true
+
+proc retrFile*(ftp: var TFTPClient, file, dest: string, async = false) =
+  ## Downloads ``file`` and saves it to ``dest``. Usage of this function
+  ## asynchronously is recommended to view the progress of the download.
+  ftp.createJob(asyncFile, JRetr)
+  ftp.job.file = open(dest, mode = fmWrite)
+  ftp.pasv()
+  var reply = ftp.send("RETR " & file)
+  assertReply reply, ["125", "150"]
+  if {'(', ')'} notin reply:
+    raise newException(EInvalidReply, "Reply has no file size.")
+  var fileSize: biggestInt
+  assert reply.captureBetween('(', ')').parseBiggestInt(fileSize) != 0
+  ftp.job.total = fileSize
+  ftp.job.lastProgressReport = epochTime()
+
+  if not async:
+    while not ftp.job.prc(ftp, 500): nil
+    ftp.deleteJob()
+
+proc asyncUpload(ftp: var TFTPClient, timeout: int): bool =
+  var writeSocks: seq[TSocket] = @[ftp.dsock]
+  var readSocks: seq[TSocket] = @[ftp.csock]
+
+  if select(readSocks, writeSocks, timeout) != 0:
+    if ftp.dsock notin writeSocks and not ftp.job.dsockClosed:
+      var buffer: array[0..1023, byte]
+      var len = ftp.job.file.readBytes(buffer, 0, 1024)
+      if len == 0:
+        # File finished uploading.
+        ftp.dsock.close()
+        ftp.job.dsockClosed = true
+        return
+
+      if ftp.dsock.send(addr(buffer), len) != len: assert(false)
+      ftp.job.progress.inc(len)
+      ftp.job.oneSecond.inc(len)
+  
+    if ftp.csock notin readSocks:
+      # TODO: Why does this block? Why does select 
+      # think that the socket is readable?
+      assertReply ftp.expectReply(), "226"
+      return true
+
+proc store*(ftp: var TFTPClient, file, dest: string, async = false) =
+  ## Uploads ``file`` to ``dest`` on the remote FTP server. Usage of this
+  ## function asynchronously is recommended to view the progress of
+  ## the download.
+  ftp.createJob(asyncUpload, JStore)
+  ftp.job.file = open(file)
+  ftp.job.total = ftp.job.file.getFileSize()
+  ftp.job.lastProgressReport = epochTime()
+  ftp.pasv()
+  
+  assertReply ftp.send("STOR " & dest), ["125", "150"]
+
+  if not async:
+    while not ftp.job.prc(ftp, 500): nil
+    ftp.deleteJob()
+
+proc poll*(ftp: var TFTPClient, r: var TFTPEvent, timeout = 500): bool =
+  ## Progresses an async job(if available). Returns true if ``r`` has been set.
+  if ftp.jobInProgress:
+    if ftp.job.prc(ftp, timeout):
+      result = true
+      case ftp.job.typ
+      of JListCmd, JRetrText:
+        r.typ = EvLines
+        r.lines = ftp.job.lines
+      of JRetr:
+        r.typ = EvRetr
+        if ftp.job.progress != ftp.job.total:
+          raise newException(EFTP, "Didn't download full file.")
+      of JStore:
+        r.typ = EvStore
+        if ftp.job.progress != ftp.job.total:
+          raise newException(EFTP, "Didn't upload full file.")
+      ftp.deleteJob()
+      return
+    
+    if ftp.job.typ in {JRetr, JStore}:
+      if epochTime() - ftp.job.lastProgressReport >= 1.0:
+        result = true
+        ftp.job.lastProgressReport = epochTime()
+        r.typ = EvTransferProgress
+        r.bytesTotal = ftp.job.total
+        r.bytesFinished = ftp.job.progress
+        r.speed = ftp.job.oneSecond
+        ftp.job.oneSecond = 0
+
+proc close*(ftp: var TFTPClient) =
+  ## Terminates the connection to the server.
+  assertReply ftp.send("QUIT"), "221"
+  if ftp.jobInProgress: ftp.deleteJob()
+  ftp.csock.close()
+  ftp.dsock.close()
+
+when isMainModule:
+  import os
+  var ftp = FTPClient("ex.org", user = "user", pass = "p")
+  ftp.connect()
+  echo ftp.pwd()
+  echo ftp.list()
+
+  ftp.store("payload.avi", "payload.avi", async = true)
+  while True:
+    var event: TFTPEvent
+    if ftp.poll(event):
+      case event.typ
+      of EvStore:
+        echo("Upload finished!")
+        break
+      of EvTransferProgress:
+        var time: int64 = -1
+        if event.speed != 0:
+          time = (event.bytesTotal - event.bytesFinished) div event.speed
+        echo(event.speed div 1000, " kb/s. - ",
+             event.bytesFinished, "/", event.bytesTotal,
+             " - ", time, " seconds")
+
+      else: assert(false)
+
+  ftp.retrFile("payload.avi", "payload2.avi", async = true)
+  while True:
+    var event: TFTPEvent
+    if ftp.poll(event):
+      case event.typ
+      of EvRetr:
+        echo("Download finished!")
+        break
+      of EvTransferProgress:
+        echo(event.speed div 1000, " kb/s")
+      else: assert(false)
+
+  sleep(5000)
+  ftp.close()
+  sleep(200)