Tasmota/tasmota/berry/drivers/ftp.be

565 lines
17 KiB
Plaintext

#- Simple FTP server in Berry by Christian Baars
# supports active and passive mode - but passive is preferred!
# only light error handling
-#
class PATH # helper class to hold the current directory
var p # path components in a list
def init()
import string
self.p = []
end
def set(p)
import string
import path
if path.isdir(p) != true
return false
end
var new = string.split(p,"/")
self.p = []
for c:new
if c != ""
self.p.push(c)
end
end
return true
end
def dir_up()
if size(self.p) > 0
self.p.pop()
end
end
def get_url()
var url = "/"
for c:self.p
if c != ""
url += f"{c}/"
end
end
return url
end
end
class FTP : Driver
var connection, server, client, data_server, data_client, data_ip
var dir, dir_list, dir_pos
var file, file_size, file_rename, retries, chunk_size
var binary_mode, active_ip, active_port, user_input
var data_buf, data_ptr, fast_loop, data_op
static port = 21
static data_port = 20 # data connection in passive mode
static allow_anonymous = true # allow everything ..
static user = "user"
static password = "pass"
def init()
self.server = tcpserver(self.port) # connection for control data
self.connection = false
self.data_ip = tasmota.wifi()['ip']
self.dir = PATH()
self.readDir()
self.data_ptr = 0
self.active_port = nil
tasmota.add_driver(self)
log(f"FTP: init server on port {self.port}",1)
end
def deinit()
self.server.deinit()
self.data_server.deinit()
tasmota.remove_driver(self)
end
def every_50ms()
if self.connection == true
self.loop()
elif self.server.hasclient()
self.client = self.server.acceptasync()
self.sendResponse("220 Welcome")
self.connection = true
self.pubClientInfo()
else
self.connection = false
end
end
def every_second()
if self.client && self.connection != false
if self.client.connected() == false
self.pubClientInfo()
self.connection = false
self.abortDataOp()
end
end
end
def pubClientInfo()
import mqtt
var payload = self.client.info().tostring()
mqtt.publish("FTP",format("{'server':%s}", payload))
end
def loop()
if self.connection == true
self.handleConnection()
end
end
def abortDataOp()
if self.data_op == "d"
self.finishDownload(true)
elif self.data_op == "u"
self.finishUpload(true)
elif self.data_op == "dir"
self.finishUpload(false)
end
end
def download() # ESP -> client
self.data_buf..self.file.readbytes(self.chunk_size)
if size(self.data_buf) == 0
self.retries -= 1
if self.retries > 0
return
end
else
var written = self.data_client.write(self.data_buf)
self.data_buf.clear()
self.data_ptr += written
if self.data_ptr < self.file_size
self.file.seek(self.data_ptr)
if self.retries > 0
return
end
end
end
self.finishDownload()
end
def finishDownload(error)
self.data_client.close()
tasmota.remove_fast_loop(self.fast_loop)
self.file.close()
if error
self.sendResponse(f"426 Connection closed; transfer aborted after {self.data_ptr} bytes.")
else
self.sendResponse(f"250 download done with {self.data_ptr} bytes.")
end
self.data_op = nil
tasmota.gc()
end
def upload() # client -> ESP
self.data_buf..self.data_client.readbytes()
if size(self.data_buf) > 0
self.file.write(self.data_buf)
self.data_ptr += size(self.data_buf)
self.data_buf.clear()
else
log(f"FTP: {self.retries} retries",4)
self.retries -= 1
if self.retries > 0
return
end
self.finishUpload()
end
end
def finishUpload(error)
self.data_client.close()
tasmota.remove_fast_loop(self.fast_loop)
self.file.close()
if error
self.sendResponse(f"426 Connection closed; transfer after {self.data_ptr} bytes")
else
self.sendResponse(f"250 upload done with {self.data_ptr} bytes")
end
self.data_op = nil
tasmota.gc()
end
def transferDir(mode)
import path
var sz, date, isdir
var i = self.dir_list[self.dir_pos]
var url = f"{self.dir.get_url()}{i}"
isdir = path.isdir(url)
if isdir == false
var f = open(url,"r")
sz = f.size()
f.close()
date = path.last_modified(url)
end
if self.data_client.connected()
var dir = ""
if mode == "MLSD"
if isdir
dir = "Type=dir;Perm=edlmp; "
else
date = tasmota.time_dump(date)
var y = str(date['year'])
var m = f"{date['month']:02s}"
var d = f"{date['day']:02s}"
var h = f"{date['hour']:02s}"
var min = f"{date['min']:02s}"
var sec = f"{date['sec']:02s}"
var modif =f"{y}{m}{d}{h}{min}{sec}"
dir = f"Type=file;Perm=rwd;Modify={modif};Size={sz}; "
end
elif mode == "LIST"
var d = "-"
if isdir
d = "d"
date = ""
sz = ""
else
date = tasmota.strftime("%b %d %H:%M", date)
end
dir = f"{d}rw------- 1 all all{sz:14s} {date} "
elif mode == "NLST"
dir=self.dir.get_url()
end
var entry = f"{dir}{i}"
log(entry,4)
self.data_client.write(entry + "\r\n")
self.dir_pos += 1
else
self.finishTransferDir(false)
end
if self.dir_pos < size(self.dir_list)
return
end
self.finishTransferDir(true)
end
def finishTransferDir(success)
self.data_client.close()
if success
var n = size(self.dir_list)
self.sendResponse(f"226 {n} files in {self.dir.get_url()}")
else
self.sendResponse("426 Transfer aborted")
end
self.data_op = nil
tasmota.remove_fast_loop(self.fast_loop)
tasmota.gc()
end
def readDir()
import path
self.dir_list = path.listdir(self.dir.get_url())
end
def openFile(name,mode)
import path
var url = f"{self.dir.get_url()}{name}"
if path.isdir(url) == true
log(f"FTP: {url} is a folder",2)
return false
end
if mode == "r"
if path.exists(url) != true
log(f"FTP: {url} not found",2)
return false
end
end
log(f"FTP: Open file {url} in {mode} mode",3)
self.file = open(f"{url}",mode)
if mode == "a"
if self.data_ptr != 0
log(f"FTP: Appending file {url} at position {self.data_ptr}",3)
if self.data_ptr != self.file.size()
log(f"FTP: !!! resume position of {self.data_ptr} != file size of {self.file.size()} !!!",2)
end
end
end
return true
end
def close()
self.sendResponse("221 Closing connection")
self.connection = false
end
def deinitConnectServer()
if self.data_server != nil
self.data_server.close()
self.data_server.deinit()
self.data_server = nil
log("FTP: Delete server for passive data connection",2)
end
end
def initConnectServer()
if self.data_server == nil
self.data_server = tcpserver(self.data_port)
log("FTP: Start server for passive data connection",2)
end
end
def connectActive()
self.data_client = tcpclientasync()
if self.data_client.connect(self.active_ip,self.active_port) != false
log(f"FTP: Try to connect to {self.active_ip}:{self.active_port}",3)
end
end
def connectPassive()
if self.data_server.hasclient()
self.data_client = self.data_server.acceptasync()
end
end
def dataconnect()
if self.data_client != nil
self.data_client.close()
self.data_client.deinit()
end
if self.active_port != nil
self.connectActive()
else
self.connectPassive()
end
if self.data_client == nil
self.sendResponse("425 Data connection failed")
return false
end
self.data_buf = bytes()
self.retries = 10
self.chunk_size = 5760
self.sendResponse("150 Ready for data transfer")
return true
end
def sendResponse(resp)
self.client.write(f"{resp}\r\n")
log(f"FTP: Response: {resp}",3)
end
def handleConnection() # main loop for incoming commands
import string
import mqtt
import path
var d = self.client.read()
if size(d) == 0 return end
var items = string.split(d," ")
var cmd = items[0]
var arg = ""
var response = ""
if size(items) > 1
arg = string.split(items[1],'\r\n')[0]
else
cmd = string.split(cmd,'\r\n')[0]
end
log(f"FTP: Received: {cmd} {arg}",3)
# connect
if cmd == "USER"
if self.allow_anonymous
response = "230 accept any/anonymous user"
else
self.user_input = arg
response = "331 Password required"
end
elif cmd == "PASS"
if self.user_input == self.user && arg == self.password
response = "230 User accepted"
else
response = "530 Wrong login credentials"
mqtt.publish("FTP","{'login':'wrong credentials'}")
end
elif cmd == "AUTH"
response = f"500 Server does not support {arg}"
elif cmd == "ABOR"
self.abortDataOp()
response = f"200 Aborting"
elif cmd == "QUIT"
self.close()
#options
elif cmd == "FEAT"
self.sendResponse("211-Extensions supported:")
self.sendResponse(" MLSD")
self.sendResponse(" EPSV")
self.sendResponse(" SIZE")
# self.sendResponse(" MDTM")
self.sendResponse(" REST STREAM")
response = "211 End"
elif cmd == "OPTS"
if arg == "UTF8"
response = "200 UTF Ok"
else
response = f"500 Server does not support {arg}"
end
elif cmd == "STRU"
if arg == "F"
response = "200 F Ok"
else
response = "504 Only F (ile) is supported"
end
elif cmd == "SYST"
response = "215 UNIX"
elif cmd == "LPRT"
response = f"501 active connection with long address not supported"
elif cmd == "PORT"
var el = string.split(arg,",")
self.active_ip = f"{el[0]}.{el[1]}.{el[2]}.{el[3]}"
self.active_port = int(el[4])*256 + int(el[5])
response = f"200 port received {self.active_ip}:{self.active_port}"
self.deinitConnectServer()
# response = f"501 active connection not supported"
elif cmd == "EPRT"
var el = string.split(arg,"|") # |1|192.168.1.54|65519| -> 1 IPV4, 2 IPV6
self.active_ip = el[2]
self.active_port = int(el[3])
self.deinitConnectServer()
response = f"200 extended port received {self.active_ip}:{self.active_port}"
elif cmd == "TYPE"
if arg == "I"
response = "200 binary mode"
self.binary_mode = true
elif arg == "A"
response = "200 ascii mode"
self.binary_mode = false
end
elif cmd == "EPSV"
self.active_port = nil
self.initConnectServer()
response = f"229 Entering Extended Passive Mode (|||{self.data_port}|)"
elif cmd == "PASV"
self.active_port = nil
var el = string.split(self.data_ip,".")
var hi = self.data_port >> 8
var lo = self.data_port & 0xff
self.initConnectServer()
response = f"227 Entering passive mode ({el[0]},{el[1]},{el[2]},{el[3]},{hi},{lo})"
elif cmd == "DELE"
if path.remove(f"{self.dir.get_url()}{arg}")
response = f"250 {self.dir.get_url()}{arg} deleted"
else
response = f"550 Could not delete file {self.dir.get_url()}{arg}"
end
elif cmd == "RMD"
var url = arg
if arg[0] != "/"
url = f"{self.dir.get_url()}{arg}"
end
if path.rmdir(url)
response = f"250 {url} deleted"
else
response = f"550 Could not delete folder {url}"
end
elif cmd == "STOR"
self.dataconnect()
if self.data_client != nil
response = ""
var mode = "w"
if self.data_ptr > 0
mode = "a"
end
if self.openFile(arg,mode)
self.data_op = "u"
self.fast_loop = /->self.upload()
tasmota.add_fast_loop(self.fast_loop)
else
response = f"550 Could not open file"
end
else
response = f"501 Could not init data connection"
end
elif cmd == "REST"
self.data_ptr = int(arg)
response = f"350 {self.data_ptr}"
elif cmd == "RNFR"
if self.openFile(arg,"r")
self.file_rename = f"{self.dir.get_url()}{arg}"
response = f"350 {arg}"
self.file.close()
else
self.file_rename = nil
response = f"550 Could not open file"
end
elif cmd == "RNTO"
if self.file_rename != nil
tasmota.cmd(f"UfsRename {self.file_rename},{self.dir.get_url()}{arg}")
response = f"250 Renamed {self.file_rename} -> {arg}"
else
response = f"550 Could not rename file"
end
self.file_rename = nil
elif cmd == "SIZE"
if self.openFile(arg,"r")
response = f"213 {self.file.size()}"
self.file.close()
else
response = f"550 Could not open file"
end
elif cmd == "RETR"
self.dataconnect()
if self.data_client != nil
if self.openFile(arg,"r")
self.file_size = self.file.size()
self.data_op = "d"
self.fast_loop = /->self.download()
tasmota.add_fast_loop(self.fast_loop)
else
response = f"550 Could not open file"
end
else
response = f"501 Could not init data connection"
end
# folder
elif cmd == "CDUP"
self.dir.dir_up()
response = "250 okay"
elif cmd == "CWD"
if self.dir.set(arg)
response = "250 okay"
else
response = "550 Failed to change directory."
end
elif cmd == "PWD"
self.readDir()
response = f"250 {self.dir.get_url()}"
elif cmd == "MKD"
path.mkdir(f"{self.dir.get_url()}{arg}")
response = f"250 {self.dir.get_url()}{arg} created"
elif cmd == "LIST" || cmd == "MLSD" || cmd == "NLST"
if arg != ""
self.dir.set(arg)
end
self.readDir()
if self.dataconnect()
if size(self.dir_list) > 0
self.data_op = "dir"
self.dir_pos = 0
self.fast_loop = /->self.transferDir(cmd)
tasmota.add_fast_loop(self.fast_loop)
else
self.finishTransferDir(true)
end
else
response = f"501 Could not init data connection"
end
else # any unknown command
response = "202 Command not implemented in Berry FTP"
end
if response != ""
self.sendResponse(response)
end
end
end
var ftp = FTP()