about summary refs log tree commit diff stats
diff options
context:
space:
mode:
authorbptato <nincsnevem662@gmail.com>2024-03-21 00:45:48 +0100
committerbptato <nincsnevem662@gmail.com>2024-03-21 00:46:57 +0100
commit17969768a30467c2f873a51178aea1428f83f4bc (patch)
tree935629878f8aeb907fc8283eeae4641cb85e85c4
parent91bcd7b7c7d7e22dfe26d5caa973c98751823f8f (diff)
downloadchawan-17969768a30467c2f873a51178aea1428f83f4bc.tar.gz
ftp: basic sftp support
it still sucks, but it is at least slightly more usable.

this also fixes a bug in dirlist where sort would mess up item name
association
-rw-r--r--adapter/protocol/curl.nim4
-rw-r--r--adapter/protocol/curlwrap.nim5
-rw-r--r--adapter/protocol/dirlist.nim9
-rw-r--r--adapter/protocol/ftp.nim146
-rw-r--r--doc/protocols.md11
5 files changed, 135 insertions, 40 deletions
diff --git a/adapter/protocol/curl.nim b/adapter/protocol/curl.nim
index a12e81c4..810435d2 100644
--- a/adapter/protocol/curl.nim
+++ b/adapter/protocol/curl.nim
@@ -111,10 +111,14 @@ type
     CURLOPT_WRITEDATA = CURLOPTTYPE_CBPOINT + 1
     CURLOPT_URL = CURLOPTTYPE_STRINGPOINT + 2
     CURLOPT_PROXY = CURLOPTTYPE_STRINGPOINT + 4
+    CURLOPT_ERRORBUFFER = CURLOPTTYPE_OBJECTPOINT + 10
     CURLOPT_POSTFIELDS = CURLOPTTYPE_OBJECTPOINT + 15
     CURLOPT_HTTPHEADER = CURLOPTTYPE_SLISTPOINT + 23
+    CURLOPT_KEYPASSWD = CURLOPTTYPE_STRINGPOINT + 26
     CURLOPT_HEADERDATA = CURLOPTTYPE_CBPOINT + 29
     CURLOPT_ACCEPT_ENCODING = CURLOPTTYPE_STRINGPOINT + 102
+    CURLOPT_SSH_PUBLIC_KEYFILE = CURLOPTTYPE_STRINGPOINT + 152
+    CURLOPT_SSH_PRIVATE_KEYFILE = CURLOPTTYPE_STRINGPOINT + 153
     CURLOPT_MIMEPOST = CURLOPTTYPE_OBJECTPOINT + 269
     CURLOPT_CURLU = CURLOPTTYPE_OBJECTPOINT + 282
     CURLOPT_PREREQDATA = CURLOPTTYPE_CBPOINT + 313
diff --git a/adapter/protocol/curlwrap.nim b/adapter/protocol/curlwrap.nim
index 523c260f..c3c69a68 100644
--- a/adapter/protocol/curlwrap.nim
+++ b/adapter/protocol/curlwrap.nim
@@ -9,8 +9,11 @@ template setopt*(curl: CURL, opt: CURLoption, arg: string) =
 template getinfo*(curl: CURL, info: CURLINFO, arg: typed) =
   discard curl_easy_getinfo(curl, info, arg)
 
+template set*(url: CURLU, part: CURLUPart, content: cstring, flags: cuint) =
+  discard curl_url_set(url, part, content, flags)
+
 template set*(url: CURLU, part: CURLUPart, content: string, flags: cuint) =
-  discard curl_url_set(url, part, cstring(content), flags)
+  url.set(part, cstring(content), flags)
 
 template get*(url: CURLU, part: CURLUPart, flags: cuint): cstring =
   var outs: cstring
diff --git a/adapter/protocol/dirlist.nim b/adapter/protocol/dirlist.nim
index d0933fed..a8623f35 100644
--- a/adapter/protocol/dirlist.nim
+++ b/adapter/protocol/dirlist.nim
@@ -17,7 +17,7 @@ type DirlistItem* = object
   of ITEM_DIR:
     discard
 
-type NameWidthTuple = tuple[name: string, width: int]
+type NameWidthTuple = tuple[name: string, width: int, item: ptr DirlistItem]
 
 func makeDirlist*(items: seq[DirlistItem]): string =
   var names: seq[NameWidthTuple]
@@ -30,12 +30,11 @@ func makeDirlist*(items: seq[DirlistItem]): string =
       name &= '/'
     let w = name.width()
     maxw = max(w, maxw)
-    names.add((name, w))
+    names.add((name, w, unsafeAddr item))
   names.sort(proc(a, b: NameWidthTuple): int = cmp(a.name, b.name))
   var outs = "<A HREF=\"../\">[Upper Directory]</A>\n"
-  for i in 0 ..< items.len:
-    let item = items[i]
-    var (name, width) = names[i]
+  for (name, width, itemp) in names.mitems:
+    let item = itemp[]
     var path = percentEncode(item.name, PathPercentEncodeSet)
     if item.t == ITEM_LINK:
       if item.linkto.len > 0 and item.linkto[^1] == '/':
diff --git a/adapter/protocol/ftp.nim b/adapter/protocol/ftp.nim
index 41c2183f..88ced6be 100644
--- a/adapter/protocol/ftp.nim
+++ b/adapter/protocol/ftp.nim
@@ -1,8 +1,5 @@
-when NimMajor >= 2:
-  import std/envvars
-else:
-  import std/os
 import std/options
+import std/os
 import std/strutils
 
 import curl
@@ -21,8 +18,24 @@ type FtpHandle = ref object
   path: string
   statusline: bool
 
-proc curlWriteHeader(p: cstring, size: csize_t, nitems: csize_t,
-    userdata: pointer): csize_t {.cdecl.} =
+proc printHeader(op: FtpHandle) =
+    if op.dirmode:
+      stdout.write("""Content-Type: text/html
+
+<HTML>
+<HEAD>
+<BASE HREF=""" & op.base & """>
+<TITLE>""" & op.path & """</TITLE>
+</HEAD>
+<BODY>
+<H1>Index of """ & htmlEscape(op.path) & """</H1>
+<PRE>
+""")
+    else:
+      stdout.write('\n')
+
+proc curlWriteHeader(p: cstring; size, nitems: csize_t; userdata: pointer):
+    csize_t {.cdecl.} =
   var line = newString(nitems)
   if nitems > 0:
     prepareMutation(line)
@@ -34,20 +47,7 @@ proc curlWriteHeader(p: cstring, size: csize_t, nitems: csize_t,
       var status: clong
       op.curl.getinfo(CURLINFO_RESPONSE_CODE, addr status)
       stdout.write("Status: " & $status & "\n")
-      if op.dirmode:
-        stdout.write("Content-Type: text/html\n")
-      stdout.write("\n")
-      if op.dirmode:
-        stdout.write("""
-<HTML>
-<HEAD>
-<BASE HREF=""" & op.base & """>
-<TITLE>""" & op.path & """</TITLE>
-</HEAD>
-<BODY>
-<H1>Index of """ & htmlEscape(op.path) & """</H1>
-<PRE>
-""")
+      op.printHeader()
       return nitems
     elif line.startsWith("530"): # login incorrect
       op.statusline = true
@@ -72,14 +72,16 @@ Content-Type: text/html
   return nitems
 
 # From the documentation: size is always 1.
-proc curlWriteBody(p: cstring, size, nmemb: csize_t, userdata: pointer):
+proc curlWriteBody(p: cstring; size, nmemb: csize_t; userdata: pointer):
     csize_t {.cdecl.} =
   let op = cast[FtpHandle](userdata)
+  if not op.statusline:
+    op.statusline = true
+    op.printHeader()
   if nmemb > 0:
     if op.dirmode:
       let i = op.buffer.len
       op.buffer.setLen(op.buffer.len + int(nmemb))
-      prepareMutation(op.buffer)
       copyMem(addr op.buffer[i], p, nmemb)
     else:
       return csize_t(stdout.writeBuffer(p, int(nmemb)))
@@ -87,7 +89,7 @@ proc curlWriteBody(p: cstring, size, nmemb: csize_t, userdata: pointer):
 
 proc finish(op: FtpHandle) =
   let op = op
-  var items: seq[DirlistItem]
+  var items: seq[DirlistItem] = @[]
   for line in op.buffer.split('\n'):
     if line.len == 0: continue
     var i = 10 # permission
@@ -151,10 +153,70 @@ proc finish(op: FtpHandle) =
   stdout.write(makeDirlist(items))
   stdout.write("\n</PRE>\n</BODY>\n</HTML>\n")
 
+proc matchesPattern(s, pat: openArray[char]): bool =
+  var i = 0
+  for j, c in pat:
+    if c == '*':
+      while i < s.len:
+        if s.toOpenArray(i, s.high).matchesPattern(pat.toOpenArray(j + 1,
+            pat.high)):
+          return true
+        inc i
+      return false
+    if i >= s.len or c != '?' and c != s[i]:
+      return false
+    inc i
+  return true
+
+proc parseSSHConfig(f: File; curl: CURL; host: string; idSet: var bool) =
+  var skipTillNext = false
+  var line: string
+  var certificateFile = ""
+  var identityFile = ""
+  while f.readLine(line):
+    var i = line.skipBlanks(0)
+    if i == line.len or line[i] == '#':
+      continue
+    let k = line.until(AsciiWhitespace, i)
+    i = line.skipBlanks(i + k.len)
+    if i < line.len and line[i] == '=':
+      i = line.skipBlanks(i + 1)
+    if i == line.len or line[i] == '#':
+      continue
+    var arg = ""
+    let isStr = line[i] in {'"', '\''}
+    if isStr:
+      inc i
+    var quot = false
+    while i < line.len and (quot or line[i] != '"' or not isStr):
+      if not quot and line[i] == '\\':
+        quot = true
+      else:
+        arg &= line[i]
+      inc i
+    if k == "Match": #TODO support this
+      skipTillNext = true
+    elif k == "Host":
+      skipTillNext = not host.matchesPattern(arg)
+    elif skipTillNext:
+      continue
+    elif k == "IdentityFile":
+      identityFile = expandTilde(arg)
+    elif k == "CertificateFile":
+      certificateFile = expandTilde(arg)
+  if identityFile != "":
+    curl.setopt(CURLOPT_SSH_PRIVATE_KEYFILE, identityFile)
+    idSet = true
+  if certificateFile != "":
+    curl.setopt(CURLOPT_SSH_PUBLIC_KEYFILE, certificateFile)
+  f.close()
+
 proc main() =
   let curl = curl_easy_init()
   doAssert curl != nil
-  let opath = getEnv("MAPPED_URI_PATH")
+  var opath = getEnv("MAPPED_URI_PATH")
+  if opath == "":
+    opath = "/"
   let path = percentDecode(opath)
   let op = FtpHandle(
     curl: curl,
@@ -162,14 +224,30 @@ proc main() =
   )
   let url = curl_url()
   const flags = cuint(CURLU_PATH_AS_IS)
-  url.set(CURLUPART_SCHEME, getEnv("MAPPED_URI_SCHEME"), flags)
+  let scheme = getEnv("MAPPED_URI_SCHEME")
+  url.set(CURLUPART_SCHEME, scheme, flags)
   let username = getEnv("MAPPED_URI_USERNAME")
   if username != "":
     url.set(CURLUPART_USER, username, flags)
+  let host = getEnv("MAPPED_URI_HOST")
   let password = getEnv("MAPPED_URI_PASSWORD")
-  if password != "":
-    url.set(CURLUPART_PASSWORD, password, flags)
-  url.set(CURLUPART_HOST, getEnv("MAPPED_URI_HOST"), flags)
+  var idSet = false
+  # Parse SSH config for sftp.
+  if scheme == "sftp":
+    let systemConfig = "/etc/ssh/ssh_config"
+    if fileExists(systemConfig):
+      var f: File
+      if f.open(systemConfig):
+        parseSSHConfig(f, curl, host, idSet)
+    let userConfig = expandTilde("~/.ssh/config")
+    if fileExists(userConfig):
+      var f: File
+      if f.open(userConfig):
+        parseSSHConfig(f, curl, host, idSet)
+    if idSet:
+      curl.setopt(CURLOPT_KEYPASSWD, password)
+  url.set(CURLUPART_PASSWORD, password, flags)
+  url.set(CURLUPART_HOST, host, flags)
   let port = getEnv("MAPPED_URI_PORT")
   if port != "":
     url.set(CURLUPART_PORT, port, flags)
@@ -192,7 +270,12 @@ proc main() =
     op.path = path
     curl_free(surl)
   # Now for the workaround:
-  url.set(CURLUPART_PATH, '/' & opath, flags)
+  if scheme != "sftp" and (opath.len <= 1 or opath[1] != '~'):
+    url.set(CURLUPART_PATH, '/' & opath, flags)
+  # Another hack: if password was set for the identity file, then clear it from
+  # the URL.
+  if idSet:
+    url.set(CURLUPART_PASSWORD, nil, flags)
   # Set opts for the request
   curl.setopt(CURLOPT_CURLU, url)
   curl.setopt(CURLOPT_HEADERDATA, op)
@@ -211,7 +294,10 @@ proc main() =
     let res = curl_easy_perform(curl)
     if res != CURLE_OK:
       if not op.statusline:
-        stdout.write(getCurlConnectionError(res))
+        if res == CURLE_LOGIN_DENIED:
+          stdout.write("Status: 401\n")
+        else:
+          stdout.write(getCurlConnectionError(res))
     elif op.dirmode:
       op.finish()
   curl_url_cleanup(url)
diff --git a/doc/protocols.md b/doc/protocols.md
index 84a8dde1..6eb07112 100644
--- a/doc/protocols.md
+++ b/doc/protocols.md
@@ -52,11 +52,14 @@ not work.
 Chawan supports FTP through the `adapter/protocol/ftp.nim` libcurl adapter. For
 directory listings, it assumes UNIX output style, and will probably break
 horribly on receiving anything else. Otherwise, the directory listing view
-is identical to (and uses the same code path as) the file:// directory listing.
+is identical to the file:// directory listing.
 
-In theory, SFTP and FTPS should be supported as well. In practice, SFTP does
-not really work yet because there is no way to specify private keys, and I
-have never seen an FTPS server in the wild so I assume it is broken too.
+SFTP "works" too, but YMMV. Note that if an IdentityFile declaration is found in
+your ssh config, then it will prompt for the identity file password, but there
+is no way to tell whether it is really asking for that. Also, settings covered
+by the Match field are ignored.
+
+In theory, FTPS should work too, but it is completely untested.
 
 ## Gopher