FTP rework

This commit is contained in:
smok1e 2025-09-06 15:24:18 +03:00
parent 283d0fca68
commit 3f34c05a7b

928
Libraries/FTP.lua Normal file
View File

@ -0,0 +1,928 @@
local event = require("Event")
local filesystem = require("Filesystem")
local FTP = {}
----------------------------------------------------------------------------------------------------------------
-- Connect to address:port and wait for the socket to finish connection until timeout exceeded
local function socketConnect(address, port, timeout)
local socket, reason = component.get("internet").connect(address, port)
local connectionStartTime = computer.uptime()
while true do
local success, reason = socket.finishConnect()
if success then
return socket
end
if success == nil then
return nil, reason
end
if computer.uptime() - connectionStartTime > timeout then
return nil, "connection timed out"
end
end
end
-- Wait for internet_ready event until timeout exceeded
local function socketAwait(socket, timeout)
local startTime = computer.uptime()
while true do
local eventType, _, socketId = event.pull(timeout - (computer.uptime() - startTime))
if not eventType then
return false
end
if eventType == "internet_ready" and socketId == socket.id() then
return true
end
end
end
-- Read remaining data from the socket
local function socketReceive(socket)
local data = ""
while true do
local chunk = socket.read()
if not chunk or #chunk == 0 then
break
end
data = data .. chunk
end
return data
end
----------------------------------------------------------------------------------------------------------------
-- FTP response line iterator
function FTP.lines(data)
return data:gmatch("([^\r\n]+)\r\n")
end
-- Convert FTP modification time into unix timestamp
function FTP.parseTimestamp(timestamp)
local year, month, day, hour, min, sec = timestamp:match("(%d%d%d%d)(%d%d)(%d%d)(%d%d)(%d%d)(%d%d)")
return os.time({
year = tonumber(year),
month = tonumber(month),
day = tonumber(day),
hour = tonumber(hour),
min = tonumber(min),
sec = tonumber(sec)
})
end
-- File information returned by MLSD/MLST commands follows key=value; format:
-- type=dir;sizd=4096;modify=20240924194606; home
function FTP.parseFileInfo(fileInfo)
local info = {}
for token in fileInfo:gmatch("[^; ]+") do
local key, value = token:match("(.+)=(.+)")
if key then
key = key:lower()
if key == "type" then
info.isdir = not not value:match("dir")
elseif key:match("siz.") then
info.size = tonumber(value)
elseif key == "modify" then
info.modify = FTP.parseTimestamp(value)
end
else
info.name = token
end
end
return info
end
----------------------------------------------------------------------------------------------------------------
-- Receive and parse FTP command response
-- If the function succeedes, the return values will be first responses and a table containing all parsed responses
-- Most of the time second value may be discarded, though it may be useful when sending multiple commands in a single request
local function FTPReceiveResponse(self)
if not socketAwait(self.controlSocket, self.responseTimeout) then
return nil, "response timed out"
end
local data, reason = socketReceive(self.controlSocket)
if not data then
return nil, reason
end
local responses, response = {}
for line in data:gmatch("[^\r\n]+\r\n") do
if not response then
response = {
content = ""
}
end
local status, delimiter, content = line:match("(%d%d%d)([ -])(.+\r\n)")
if status then
response.content = response.content .. content
-- Space after the status code indicates the last line of the response
if delimiter == " " then
response.status = tonumber(status)
response.ok = 100 <= response.status and response.status < 400
table.insert(responses, response)
response = nil
end
else
response.content = response.content .. line
end
end
if #responses < 1 then
return nil, "no parsable responses"
end
return responses[1], responses
end
-- Send FTP command and receive response
-- command argument may be either a string or a table containing multiple commands
local function FTPSendCommand(self, command)
command = (type(command) == "table" and table.concat(command, "\r\n") or command) .. "\r\n"
-- Seems like windows IIS ftp server doesn't like large command blobs
for i = 1, math.ceil(#command / self.commandChunkSize) do
local result, reason = self.controlSocket.write(
command:sub(
self.commandChunkSize * (i - 1) + 1,
self.commandChunkSize * i
)
)
if not result then
return nil, reason
end
end
return self:receiveResponse()
end
-- Enter passive mode, send command and read data connection
local function FTPSendPassiveModeCommand(self, command)
local result, reason = self:beginNegotiation()
if not result then
return nil, reason
end
local response, reason = self:sendCommand(command)
if not response then
self:endNegotiation()
return nil, reason
end
if not response.ok then
self:endNegotiation()
return nil, "command failed: " .. response.content
end
local data, reason = self:receiveData()
self:endNegotiation()
if not data then
return nil, reason
end
return data
end
-- Enter passive mode and establish data connection
local function FTPBeginNegotiation(self)
if self.dataSocket then
return nil, "data connection already open"
end
local response, reason = self:sendCommand("PASV")
if not response then
return nil, reason
end
if not response.ok then
return nil, "unable to enter passive mode: " .. response.content
end
local digits = {response.content:match("(%d+),(%d+),(%d+),(%d+),(%d+),(%d+)")}
if #digits ~= 6 then
return nil, "unable to enter passive mode: malformed response"
end
local dataSocket, reason = socketConnect(
table.concat(digits, ".", 1, 4),
tonumber(digits[5]) * 256 + tonumber(digits[6]),
self.responseTimeout
)
if not dataSocket then
return nil, reason
end
self.dataSocket = dataSocket
return true
end
-- Close data connection
local function FTPEndNegotiation(self)
if not self.dataSocket then
return nil, "data connection was not open"
end
self.dataSocket.close()
self.dataSocket = nil
return true
end
-- Read data response
local function FTPReceiveData(self)
if not self.dataSocket then
return nil, "data connection was not open"
end
return socketReceive(self.dataSocket)
end
-- Send data through the data connection
local function FTPSendData(self, data)
if not self.dataSocket then
return nil, "data connection was not open"
end
return self.dataSocket.write(data)
end
-- Close FTP connection
local function FTPClose(self)
if self.controlSocket then
self.controlSocket.close()
self.controlSocket = nil
end
if self.dataSocket then
self.dataSocket.close()
self.dataSocket = nil
end
end
----------------------------------------------------------------------------------------------------------------
local function pizda(response)
return not response or not response.ok
end
local function cyka(response, ...)
if not response then
return nil, ...
end
if not response.ok then
return nil, response.content
end
end
local function getParentDirectory(path)
local directory, filename = path:match("^(.*/)([^/]+)/?$")
if not directory then
return nil, path
end
return directory, filename
end
----------------------------------------------------------------------------------------------------------------
local function FTPSetCacheValue(self, key, value)
self.cache[key] = value ~= nil and {computer.uptime(), value} or nil
end
local function FTPGetCacheValue(self, key)
local currentTime = computer.uptime()
for key, entry in pairs(self.cache) do
if currentTime - entry[1] > self.cacheTimeout then
self.cache[key] = nil
end
end
local entry = self.cache[key]
if entry then
return entry[2]
end
end
----------------------------------------------------------------------------------------------------------------
-- Keep connection alive
local function FTPKeepAlive(self)
local response, reason = self:sendCommand("PWD")
if not response then
return nil, reason
end
return true
end
-- Login
local function FTPLogin(self, username, password)
local response, reason = self:sendCommand("USER " .. username)
if pizda(response) then
return cyka(response, reason)
end
local response, reason = self:sendCommand("PASS " .. password)
if pizda(response) then
return cyka(response, reason)
end
return true
end
-- Set negotiation mode
local function FTPSetMode(self, mode)
local response, reason = self:sendCommand("TYPE " .. mode)
if pizda(response) then
return cyka(response, reason)
end
return true
end
-- Get working directory
local function FTPGetWorkingDirectory(self, useCache)
if self.cacheTimeout and useCache then
local cache = self:getCacheValue("getWorkingDirectory")
if cache then
return cache
end
end
local response, reason = self:sendCommand("PWD")
if pizda(response) then
return cyka(response, reason)
end
local path = response.content:match("\"(.+)\"")
if not path then
return nil, "unexpected response: " .. response.content
end
if self.cacheTimeout then
self:setCacheValue("getWorkingDirectory", path)
end
return path
end
-- Set working directory
local function FTPChangeWorkingDirectory(self, path)
if self.cacheTimeout then
self:setCacheValue("getWorkingDirectory")
end
local response, reason = self:sendCommand("CWD " .. path)
if pizda(response) then
return cyka(response, reason)
end
return true
end
-- Get file size
local function FTPGetFileSize(self, path)
local response, reason = self:sendCommand("SIZE " .. path)
if pizda(response) then
return cyka(response, reason)
end
return tonumber(response.content)
end
-- Returns absolute path
local function FTPResolvePath(self, path, useCache)
if path:match("^/.*") then
return path
end
local directory, reason = self:getWorkingDirectory(useCache)
if not directory then
return nil, reason
end
return directory .. "/" .. path
end
-- This function is used internally by listDirectory and fileInfo methods
local function FTPRequestFilesInformation(self, fileNames)
local commands = { "PWD" }
for _, path in pairs(fileNames) do
-- One way to find out whether entry is directory or not without MLSD/MLST is to try to CWD to it
table.insert(commands, "CWD " .. path)
table.insert(commands, "MDTM " .. path)
table.insert(commands, "SIZE " .. path)
end
local success, responsesOrReason = self:sendCommand(commands)
if not success then
return nil, responsesOrReason
end
local workingDirectory = table.remove(responsesOrReason, 1).content:match("\"(.+)\"")
local list = {}
for _, path in pairs(fileNames) do
local entry = {}
entry.name = path:match("/([^/]+)$") or path
entry.isdir = table.remove(responsesOrReason, 1).ok
local response = table.remove(responsesOrReason, 1)
entry.modify = response.ok and FTP.parseTimestamp(response.content) or 0
local response = table.remove(responsesOrReason, 1)
entry.size = response.ok and tonumber(response.content) or 0
table.insert(list, entry)
end
self:changeWorkingDirectory(workingDirectory)
return list
end
-- List directory entries with their type, size and modification time
local function FTPListDirectory(self, path, useCache)
local reason
path, reason = self:resolvePath(path, useCache)
if not path then
return nil, "unable to resolve path: " .. reason
end
local cacheKey = "listDirectory@" .. path
if self.cacheTimeout and useCache then
local cache = self:getCacheValue(cacheKey)
if cache then
return cache
end
end
local list = {}
if self.features.MLSD then
local data, reason = self:sendPassiveModeCommand("MLSD " .. path)
if not data then
return nil, reason
end
for line in FTP.lines(data) do
local info = FTP.parseFileInfo(line)
if not info.name:match("[^%.]*%.%.?$") then
table.insert(list, info)
end
end
else
-- No MLSD :(
local data, reason = self:sendPassiveModeCommand("NLST " .. path)
if not data then
return nil, reason
end
local fileNames = {}
for name in FTP.lines(data) do
if not name:match("[^%.]*%.%.?$") then
table.insert(fileNames, name)
end
end
local reason
list, reason = self:requestFilesInformation(fileNames)
if not list then
return nil, reason
end
end
-- Sort files by name
table.sort(
list,
function(a, b)
return a.name < b.name
end
)
if self.cacheTimeout then
self:setCacheValue(cacheKey, list)
end
return list
end
-- Returns information about the requested file
local function FTPGetFileInfo(self, path, useCache)
local reason
path, reason = self:resolvePath(path, useCache)
if not path then
return nil, "unable to resolve path: " .. reason
end
local cacheKey = "getFileInfo@" .. path
if self.cacheTimeout and useCache then
-- Check for getFileInfo cache
local cache = self:getCacheValue(cacheKey)
if cache then
return cache
end
-- Check for listDirectory cache
local directory, filename = getParentDirectory(path)
if directory then
local cache = self:getCacheValue("listDirectory@" .. directory)
if cache then
for i = 1, #cache do
if cache[i].name == filename then
return cache[i]
end
end
end
end
end
local info
if self.features.MLST then
local response, reason = self:sendCommand("MLST " .. path)
if not response then
return nil, reason
end
local data = response.content:match("^ ([^\r\n]+)\r\n$")
if not data then
return nil, "unparsable response"
end
info = FTP.parseFileInfo(data)
else
local result, reason = self:requestFilesInformation({ path })
if not result then
return nil, reason
end
info = result[1]
end
if self.cacheTimeout then
self:setCacheValue(cacheKey, info)
end
return info
end
local function FTPFileExists(self, path, useCache)
local directory, filename = getParentDirectory(path)
if directory == "/" then
return true
end
local list, reason = self:listDirectory(directory or "", useCache)
if not list then
return nil, reason
end
for i = 1, #list do
if list[i].name == filename then
return true
end
end
return false
end
-- Delete file or directory recursively
local function FTPRemoveFile(self, path, useCache)
local info, reason = self:getFileInfo(path, useCache)
if not info then
return nil, reason
end
if info.isdir then
local list = self:listDirectory(path, useCache)
for i = 1, #list do
if list[i].name ~= "." and list[i].name ~= ".." then
local success, reason = self:removeFile((path .. "/" .. list[i].name):gsub("/+", "/"))
if not success then
return nil, reason
end
end
end
local response, reason = self:sendCommand("RMD " .. path)
if pizda(response) then
return cyka(response, reason)
end
else
local response, reason = self:sendCommand("DELE " .. path)
if pizda(response) then
return cyka(response, reason)
end
end
return true
end
-- Create directory
local function FTPMakeDirectory(self, path)
local response, reason = self:sendCommand("MKD " .. path)
if pizda(response) then
return cyka(response, reason)
end
return true
end
-- Rename file
local function FTPRenameFile(self, from, to)
local response, reason = self:sendCommand("RNFR " .. from)
if pizda(response) then
return cyka(response, reason)
end
local response, reason = self:sendCommand("RNTO " .. to)
if pizda(response) then
return cyka(response, reason)
end
return true
end
----------------------------------------------------------------------------------------------------------------
-- Read file by path
-- Each received fragment is passed into callback function
local function FTPReadFile(self, path, callback, chunkSize)
local result, reason = self:beginNegotiation()
if not result then
return nil, "negotiation failed: " .. result
end
local response, reason = self:sendCommand("RETR " .. path)
if pizda(response) then
self:endNegotiation()
return cyka(response, reason)
end
while true do
local chunk, reason = self.dataSocket.read(chunkSize or math.huge)
if not chunk or #chunk == 0 then
self:endNegotiation()
if reason then
return nil, "unable to receive data: " .. reason
end
break
end
local success, reason = pcall(callback, chunk)
if not success then
self:endNegotiation()
return nil, "callback error: " .. reason
end
end
self:endNegotiation()
return true
end
-- Read whole remote file into the memory
local function FTPReadFileToMemory(self, path)
local buffer = ""
local success, reason = self:readFile(
path,
function(chunk)
buffer = buffer .. chunk
end
)
if not success then
return nil, reason
end
return buffer
end
-- Read whole remote file into the local filesystem
local function FTPReadFileToFilesystem(self, path, savePath)
local file = filesystem.open(savePath, "w")
if not file then
return nil, "could not open file for writing"
end
local success, reason = self:readFile(
path,
function(chunk)
file:write(chunk)
end
)
file:close()
return not not success, reason
end
-- Write file
-- Data is obtained from the callback function until it returns nil
local function FTPWriteFile(self, path, callback)
local result, reason = self:beginNegotiation()
if not result then
return nil, "negotiation failed: " .. result
end
local response, reason = self:sendCommand("STOR " .. path)
if pizda(response) then
self:endNegotiation()
return cyka(response, reason)
end
while true do
local sucess, chunkOrReason = pcall(callback)
if not sucess then
self:endNegotiation()
return false, "callback error: " .. chunkOrReason
end
if not chunkOrReason then
break
end
self:sendData(chunkOrReason)
end
self:endNegotiation()
local result, reason = self:receiveResponse()
if not result or not result.ok then
return nil, reason or result.content
end
return true
end
-- Write file from memory
local function FTPWriteFileFromMemory(self, path, data)
local chunkSize = 8192
local chunkIndex = 0
local result, reason = self:writeFile(
path,
function()
local chunk = data:sub(chunkIndex * chunkSize + 1, (chunkIndex + 1) * chunkSize)
chunkIndex = chunkIndex + 1
if #chunk == 0 then
return
end
return chunk
end
)
if not result then
return nil, reason
end
return true
end
-- Write file from local filesystem
local function FTPWriteFileFromFilesystem(self, path, localPath)
local file, reason = filesystem.open(localPath, "r")
if not file then
return nil, reason
end
local result, reason = self:writeFile(
path,
function()
local chunk = file:read(math.huge)
if not chunk or #chunk == 0 then
return
end
return chunk
end
)
file:close()
if not result then
return nil, reason
end
return true
end
----------------------------------------------------------------------------------------------------------------
-- Connect to the FTP server
function FTP.connect(address, port, responseTimeout)
local self = {}
self.commandChunkSize = 256
self.responseTimeout = responseTimeout or 1
self.cacheTimeout = 1
self.cache = {}
local controlSocket, reason = socketConnect(address, port, self.responseTimeout)
if not controlSocket then
return nil, reason
end
self.controlSocket = controlSocket
self.receiveResponse = FTPReceiveResponse
self.sendCommand = FTPSendCommand
self.sendPassiveModeCommand = FTPSendPassiveModeCommand
self.beginNegotiation = FTPBeginNegotiation
self.endNegotiation = FTPEndNegotiation
self.receiveData = FTPReceiveData
self.sendData = FTPSendData
self.close = FTPClose
self.setCacheValue = FTPSetCacheValue
self.getCacheValue = FTPGetCacheValue
self.keepAlive = FTPKeepAlive
self.login = FTPLogin
self.setMode = FTPSetMode
self.getWorkingDirectory = FTPGetWorkingDirectory
self.changeWorkingDirectory = FTPChangeWorkingDirectory
self.getFileSize = FTPGetFileSize
self.resolvePath = FTPResolvePath
self.requestFilesInformation = FTPRequestFilesInformation
self.listDirectory = FTPListDirectory
self.getFileInfo = FTPGetFileInfo
self.fileExists = FTPFileExists
self.removeFile = FTPRemoveFile
self.makeDirectory = FTPMakeDirectory
self.renameFile = FTPRenameFile
self.readFile = FTPReadFile
self.readFileToMemory = FTPReadFileToMemory
self.readFileToFilesystem = FTPReadFileToFilesystem
self.writeFile = FTPWriteFile
self.writeFileFromMemory = FTPWriteFileFromMemory
self.writeFileFromFilesystem = FTPWriteFileFromFilesystem
-- Receiving greeting
local response, reason = self:receiveResponse()
if not response then
self:close()
return nil, "unable to receive greeting: " .. reason
end
if response.status ~= 220 then
self:close()
return nil, "the server is not able to process client: " .. response.content
end
self.greeting = response.content
-- Requesting and parsing server features
local response, reason = self:sendCommand("FEAT")
if pizda(response) then
self:close()
return nil, "unable to receive server features: " .. (reason or response.content)
end
self.features = {}
for line in FTP.lines(response.content) do
local feature = line:match("^ (%w+)$")
if feature then
self.features[feature] = true
end
end
return self
end
----------------------------------------------------------------------------------------------------------------
return FTP