From 9d7a2956932101cbb5cfe7ed6756f080434cf92a Mon Sep 17 00:00:00 2001 From: tanlongsheng <252620078@qq.com> Date: Sat, 5 Jul 2025 16:32:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0ftp=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/utils/ftpconnect/ftpconnect.dart | 6 + .../ftpconnect/src/commands/directory.dart | 98 ++++++ .../utils/ftpconnect/src/commands/file.dart | 190 +++++++++++ lib/core/utils/ftpconnect/src/ftp_entry.dart | 265 +++++++++++++++ .../utils/ftpconnect/src/ftp_exceptions.dart | 11 + lib/core/utils/ftpconnect/src/ftp_reply.dart | 24 ++ lib/core/utils/ftpconnect/src/ftp_socket.dart | 221 +++++++++++++ .../utils/ftpconnect/src/ftpconnect_base.dart | 307 ++++++++++++++++++ lib/core/utils/ftpconnect/src/logger.dart | 13 + lib/core/utils/ftpconnect/src/utils.dart | 57 ++++ 10 files changed, 1192 insertions(+) create mode 100644 lib/core/utils/ftpconnect/ftpconnect.dart create mode 100644 lib/core/utils/ftpconnect/src/commands/directory.dart create mode 100644 lib/core/utils/ftpconnect/src/commands/file.dart create mode 100644 lib/core/utils/ftpconnect/src/ftp_entry.dart create mode 100644 lib/core/utils/ftpconnect/src/ftp_exceptions.dart create mode 100644 lib/core/utils/ftpconnect/src/ftp_reply.dart create mode 100644 lib/core/utils/ftpconnect/src/ftp_socket.dart create mode 100644 lib/core/utils/ftpconnect/src/ftpconnect_base.dart create mode 100644 lib/core/utils/ftpconnect/src/logger.dart create mode 100644 lib/core/utils/ftpconnect/src/utils.dart diff --git a/lib/core/utils/ftpconnect/ftpconnect.dart b/lib/core/utils/ftpconnect/ftpconnect.dart new file mode 100644 index 000000000..8d4f8c3da --- /dev/null +++ b/lib/core/utils/ftpconnect/ftpconnect.dart @@ -0,0 +1,6 @@ +library ftpconnect; + +export 'src/ftp_exceptions.dart'; +export 'src/ftpconnect_base.dart'; +export 'src/ftp_entry.dart'; +export 'src/logger.dart'; diff --git a/lib/core/utils/ftpconnect/src/commands/directory.dart b/lib/core/utils/ftpconnect/src/commands/directory.dart new file mode 100644 index 000000000..5a3d17606 --- /dev/null +++ b/lib/core/utils/ftpconnect/src/commands/directory.dart @@ -0,0 +1,98 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import '../ftp_entry.dart'; +import '../ftp_exceptions.dart'; +import '../ftp_reply.dart'; +import '../ftp_socket.dart'; +import '../ftpconnect_base.dart'; +import '../utils.dart'; + +class FTPDirectory { + final FTPSocket _socket; + + FTPDirectory(this._socket); + + Future makeDirectory(String sName) async { + FTPReply sResponse = await (_socket.sendCommand('MKD $sName')); + + return sResponse.isSuccessCode(); + } + + Future deleteEmptyDirectory(String? sName) async { + FTPReply sResponse = await (_socket.sendCommand('rmd $sName')); + + return sResponse.isSuccessCode(); + } + + Future changeDirectory(String? sName) async { + FTPReply sResponse = await (_socket.sendCommand('CWD $sName')); + + return sResponse.isSuccessCode(); + } + + Future currentDirectory() async { + FTPReply sResponse = await _socket.sendCommand('PWD'); + if (!sResponse.isSuccessCode()) { + throw FTPConnectException( + 'Failed to get current working directory', sResponse.message); + } + + int iStart = sResponse.message.indexOf('"') + 1; + int iEnd = sResponse.message.lastIndexOf('"'); + + return sResponse.message.substring(iStart, iEnd); + } + + Future> directoryContent() async { + // Enter passive mode + FTPReply response = await _socket.openDataTransferChannel(); + + // Directoy content listing, the response will be handled by another socket + _socket.sendCommandWithoutWaitingResponse(_socket.listCommand.describeEnum); + + // Data transfer socket + int iPort = Utils.parsePort(response.message, _socket.supportIPV6); + Socket dataSocket = await Socket.connect(_socket.host, iPort, + timeout: Duration(seconds: _socket.timeout)); + //Test if second socket connection accepted or not + response = await _socket.readResponse(); + //some server return two lines 125 and 226 for transfer finished + bool isTransferCompleted = response.isSuccessCode(); + if (!isTransferCompleted && response.code != 125 && response.code != 150) { + throw FTPConnectException('Connection refused. ', response.message); + } + + List lstDirectoryListing = []; + await dataSocket.listen((Uint8List data) { + lstDirectoryListing.addAll(data); + }).asFuture(); + + await dataSocket.close(); + + if (!isTransferCompleted) { + response = await _socket.readResponse(); + if (!response.isSuccessCode()) { + throw FTPConnectException('Transfer Error.', response.message); + } + } + + // Convert MLSD response into FTPEntry + List lstFTPEntries = []; + String.fromCharCodes(lstDirectoryListing).split('\n').forEach((line) { + if (line.trim().isNotEmpty) { + lstFTPEntries.add( + FTPEntry.parse(line.replaceAll('\r', ""), _socket.listCommand), + ); + } + }); + + return lstFTPEntries; + } + + Future> directoryContentNames() async { + var list = await directoryContent(); + return list.map((f) => f.name).whereType().toList(); + } +} diff --git a/lib/core/utils/ftpconnect/src/commands/file.dart b/lib/core/utils/ftpconnect/src/commands/file.dart new file mode 100644 index 000000000..243e9a423 --- /dev/null +++ b/lib/core/utils/ftpconnect/src/commands/file.dart @@ -0,0 +1,190 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:path/path.dart'; + +import '../../ftpconnect.dart'; +import '../ftp_reply.dart'; +import '../ftp_socket.dart'; +import '../utils.dart'; + +typedef FileProgress = void Function( + double progressInPercent, int totalReceived, int fileSize); + +class FTPFile { + final FTPSocket _socket; + + FTPFile(this._socket); + + Future rename(String sOldName, String sNewName) async { + FTPReply sResponse = await (_socket.sendCommand('RNFR $sOldName')); + if (sResponse.code != 350) { + return false; + } + + sResponse = await (_socket.sendCommand('RNTO $sNewName')); + return sResponse.isSuccessCode(); + } + + Future delete(String? sFilename) async { + FTPReply sResponse = await (_socket.sendCommand('DELE $sFilename')); + + return sResponse.isSuccessCode(); + } + + Future exist(String sFilename) async { + return await size(sFilename) != -1; + } + + Future size(String? sFilename) async { + try { + FTPReply sResponse = await (_socket.sendCommand('SIZE $sFilename')); + if (!sResponse.isSuccessCode() && + _socket.transferType != TransferType.binary) { + //check if ascii mode get refused + //change to binary mode if ascii mode refused + final _socketTransferTypeBackup = _socket.transferType; + await _socket.setTransferType(TransferType.binary); + sResponse = await (_socket.sendCommand('SIZE $sFilename')); + //back to default mode + await _socket.setTransferType(_socketTransferTypeBackup); + } + return int.parse(sResponse.message.replaceAll('213 ', '')); + } catch (e) { + return -1; + } + } + + Future download( + String? sRemoteName, + File fLocalFile, { + FileProgress? onProgress, + }) async { + _socket.logger.log('Download $sRemoteName to ${fLocalFile.path}'); + //check for file existence and init totalData to receive + int fileSize = 0; + fileSize = await FTPFile(_socket).size(sRemoteName); + if (fileSize == -1) { + throw FTPConnectException('Remote File $sRemoteName does not exist!'); + } + + // Enter passive mode + FTPReply response = await _socket.openDataTransferChannel(); + + //the response will be the file, witch will be loaded with another socket + _socket.sendCommandWithoutWaitingResponse('RETR $sRemoteName'); + + // Data Transfer Socket + int lPort = Utils.parsePort(response.message, _socket.supportIPV6); + _socket.logger.log('Opening DataSocket to Port $lPort'); + final Socket dataSocket = await Socket.connect(_socket.host, lPort, + timeout: Duration(seconds: _socket.timeout)); + // Test if second socket connection accepted or not + response = await _socket.readResponse(); + //some server return two lines 125 and 226 for transfer finished + bool isTransferCompleted = response.isSuccessCode(); + if (!isTransferCompleted && response.code != 125 && response.code != 150) { + throw FTPConnectException('Connection refused. ', response.message); + } + + // Changed to listen mode instead so that it's possible to send information back on downloaded amount + _socket.logger.log('Start downloading...'); + var sink = fLocalFile.openWrite(mode: FileMode.writeOnly); + var received = 0; + await dataSocket.listen((data) { + sink.add(data); + if (onProgress != null) { + received += data.length; + var percent = ((received / fileSize) * 100).toStringAsFixed(2); + //in case that the file size is 0, then pass directly 100 + double percentVal = double.tryParse(percent) ?? 100; + if (percentVal.isInfinite || percentVal.isNaN) percentVal = 100; + onProgress(percentVal, received, fileSize); + } + }).asFuture(); + + await dataSocket.close(); + await sink.flush(); + await sink.close(); + + if (!isTransferCompleted) { + //Test if All data are well transferred + response = await _socket.readResponse(); + if (!response.isSuccessCode()) { + throw FTPConnectException('Transfer Error.', response.message); + } + } + + _socket.logger.log('File Downloaded!'); + return true; + } + + /// Upload File [fFile] to the current directory with [remoteName] (using filename if not set) + Future upload( + File fFile, { + String remoteName = '', + FileProgress? onProgress, + }) async { + _socket.logger.log('Upload File: ${fFile.path}'); + + // Enter passive mode + FTPReply response = await _socket.openDataTransferChannel(); + + // Store File + String sFilename = remoteName; + if (sFilename.isEmpty) { + sFilename = basename(fFile.path); + } + + // The response is the file to upload, witch will be managed by another socket + _socket.sendCommandWithoutWaitingResponse('STOR $sFilename'); + + // Data Transfer Socket + int iPort = Utils.parsePort(response.message, _socket.supportIPV6); + _socket.logger.log('Opening DataSocket to Port $iPort'); + final Socket dataSocket = await Socket.connect(_socket.host, iPort); + //Test if second socket connection accepted or not + response = await _socket.readResponse(); + //some server return two lines 125 and 226 for transfer finished + bool isTransferCompleted = response.isSuccessCode(); + if (!isTransferCompleted && response.code != 125 && response.code != 150) { + throw FTPConnectException('Connection refused. ', response.message); + } + + _socket.logger.log('Start uploading...'); + + var received = 0; + int fileSize = await fFile.length(); + + Stream> readStream = fFile.openRead().transform( + StreamTransformer.fromHandlers( + handleData: (data, sink) { + sink.add(data); + if (onProgress != null) { + received += data.length; + var percent = ((received / fileSize) * 100).toStringAsFixed(2); + //in case that the file size is 0, then pass directly 100 + double percentVal = double.tryParse(percent) ?? 100; + if (percentVal.isInfinite || percentVal.isNaN) percentVal = 100; + onProgress(percentVal, received, fileSize); + } + }, + ), + ); + + await dataSocket.addStream(readStream); + await dataSocket.flush(); + await dataSocket.close(); + + if (!isTransferCompleted) { + // Test if All data are well transferred + response = await _socket.readResponse(); + if (!response.isSuccessCode()) { + throw FTPConnectException('Transfer Error.', response.message); + } + } + + _socket.logger.log('File Uploaded!'); + return true; + } +} diff --git a/lib/core/utils/ftpconnect/src/ftp_entry.dart b/lib/core/utils/ftpconnect/src/ftp_entry.dart new file mode 100644 index 000000000..bdf1a3623 --- /dev/null +++ b/lib/core/utils/ftpconnect/src/ftp_entry.dart @@ -0,0 +1,265 @@ +import 'package:intl/intl.dart'; + +import 'ftp_exceptions.dart'; +import 'ftpconnect_base.dart'; + +class FTPEntry { + final String name; + final DateTime? modifyTime; + final String? permission; + final FTPEntryType type; + final int? size; + final String? unique; + final String? group; + final int? gid; + final String? mode; + final String? owner; + final int? uid; + final Map? additionalProperties; + + static final RegExp regexpLIST = RegExp(r"^([\-ld])" // Directory flag [1] + r"([\-rwxs]{9})\s+" // Permissions [2] + r"(\d+)\s+" // Number of items [3] + r"(\w+)\s+" // File owner [4] + r"(\w+)\s+" // File group [5] + r"(\d+)\s+" // File size in bytes [6] + r"(\w{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{1,2}|\d{4}))\s+" // date[7] + r"(.+)$" //file/dir name[8] + ); + + static final regexpLISTSiiServers = RegExp(r"^(.{8}\s+.{7})\s+" //date[1] + r"(.{0,5})\s+" //type file or dir [2] + r"(\d{0,24})\s+" //size [3] + r"(.+)$" //file/ dir name [4] + ); + + // Hide constructor + FTPEntry._( + this.name, + this.modifyTime, + this.permission, + this.type, + this.size, + this.unique, + this.group, + this.gid, + this.mode, + this.owner, + this.uid, + this.additionalProperties); + + factory FTPEntry.parse(String responseLine, ListCommand cmd) { + if (responseLine.trim().isEmpty) { + throw FTPConnectException("Can't parse a null or blank response line"); + } + if (cmd == ListCommand.LIST) { + return FTPEntry._parseListCommand(responseLine); + } else if (cmd == ListCommand.NLST) { + return FTPEntry._(responseLine, null, null, FTPEntryType.UNKNOWN, null, + null, null, null, null, null, null, null); + } else { + return FTPEntry._parseMLSDCommand(responseLine); + } + } + + factory FTPEntry._parseMLSDCommand(final String responseLine) { + String _name = ""; + DateTime? _modifyTime; + String? _permission; + FTPEntryType _type = FTPEntryType.UNKNOWN; + int _size = 0; + String? _unique; + String? _group; + int _gid = -1; + String? _mode; + String? _owner; + int _uid = -1; + Map _additional = {}; + + // Split and trim line + responseLine.trim().split(';').forEach((property) { + final prop = property + .split('=') + .map((part) => part.trim()) + .toList(growable: false); + + if (prop.length == 1) { + // Name + _name = prop[0]; + } else { + // Other attributes + switch (prop[0].toLowerCase()) { + case 'modify': + final String date = + prop[1].substring(0, 8) + 'T' + prop[1].substring(8); + _modifyTime = DateTime.tryParse(prop[1]) ?? DateTime.tryParse(date); + break; + case 'perm': + _permission = prop[1]; + break; + case 'size': + _size = int.parse(prop[1]); + break; + case 'type': + if (prop[1] == 'dir') { + _type = FTPEntryType.DIR; + } else if (prop[1] == 'file') { + _type = FTPEntryType.FILE; + } else { + _type = FTPEntryType.LINK; + } + break; + case 'unique': + _unique = prop[1]; + break; + case 'unix.group': + _group = prop[1]; + break; + case 'unix.gid': + _gid = int.parse(prop[1]); + break; + case 'unix.mode': + _mode = prop[1]; + break; + case 'unix.owner': + _owner = prop[1]; + break; + case 'unix.uid': + _uid = int.parse(prop[1]); + break; + default: + _additional.putIfAbsent(prop[0], () => prop[1]); + break; + } + } + }); + + return FTPEntry._(_name, _modifyTime, _permission, _type, _size, _unique, + _group, _gid, _mode, _owner, _uid, Map.unmodifiable(_additional)); + } + + ///reference http://cr.yp.to/ftp/list/binls.html + ///-rw-r--r-- 1 owner group 213 Aug 26 16:31 FileName.txt + ///d for Dir + ///- for file + /// + /// SII servers format: + /// 02-11-15 03:05PM 1410887680 directory + /// 02-11-15 03:05PM 1410887680 file.avi + factory FTPEntry._parseListCommand(final String responseLine) { + if (regexpLIST.hasMatch(responseLine)) { + return FTPEntry._parseLIST(responseLine); + } else if (regexpLISTSiiServers.hasMatch(responseLine)) { + return FTPEntry._parseLISTiis(responseLine); + } else { + throw FTPConnectException( + 'Invalid format <$responseLine> for LIST command response !'); + } + } + + factory FTPEntry._parseLIST(final String responseLine) { + String _name = ""; + DateTime? _modifyTime; + String? _persmission; + FTPEntryType _type = FTPEntryType.UNKNOWN; + int _size = 0; + String? _unique; + String? _group; + int _gid = -1; + String? _mode; + String? _owner; + int _uid = -1; + + Iterable matches = regexpLIST.allMatches(responseLine); + for (Match match in matches) { + if (match.group(1) == "-") { + _type = FTPEntryType.FILE; + } else if (match.group(1) == "d") { + _type = FTPEntryType.DIR; + } else { + _type = FTPEntryType.LINK; + } + + //permission + _persmission = match.group(2); + //nb files + //var nbFiles = match.group(3); + //owner + _owner = match.group(4); + //group + _group = match.group(5); + //size + _size = int.tryParse(match.group(6)!) ?? 0; + //date + String date = (match.group(7)!.split(" ")..removeWhere((i) => i.isEmpty)) + .join(" "); //keep only one space + //insert year + if (date.contains(':')) date = '$date ${DateTime.now().year}'; + var format = date.contains(':') ? 'MMM dd hh:mm yyyy' : 'MMM dd yyyy'; + _modifyTime = DateFormat(format, 'en_US').parse(date); + //file/dir name + _name = match.group(8)!; + } + return FTPEntry._(_name, _modifyTime, _persmission, _type, _size, _unique, + _group, _gid, _mode, _owner, _uid, {}); + } + + factory FTPEntry._parseLISTiis(final String responseLine) { + String _name = ""; + DateTime? _modifyTime; + String? _persmission; + FTPEntryType _type = FTPEntryType.UNKNOWN; + int _size = 0; + String? _unique; + String? _group; + int _gid = -1; + String? _mode; + String? _owner; + int _uid = -1; + Iterable matches = regexpLISTSiiServers.allMatches(responseLine); + for (Match match in matches) { + //date + String date = + match.group(1)!.split(" ").fold('', (previousValue, element) { + //keep only one space and add fullyear if only last 2 digits in year + if (element.isEmpty) return previousValue; + if (previousValue.isEmpty) { + return element.length <= 8 + ? element.substring(0, 6) + + DateTime.now().year.toString().substring(0, 2) + + element.substring(6, 8) + : element; + } + return '$previousValue $element'; + }); + _modifyTime = DateFormat('MM-dd-yyyy hh:mma').parse(date); + + //type + if (match.group(2)!.trim().isEmpty) { + _type = FTPEntryType.FILE; + } else if (match.group(2)!.toLowerCase().contains("dir")) { + _type = FTPEntryType.DIR; + } else { + _type = FTPEntryType.LINK; + } + //size + _size = int.tryParse(match.group(3)!) ?? 0; + + //file/dir name + _name = match.group(4)!; + } + return FTPEntry._(_name, _modifyTime, _persmission, _type, _size, _unique, + _group, _gid, _mode, _owner, _uid, {}); + } + + @override + String toString() => + 'name=$name;modify=$modifyTime;perm=$permission;type=${type.describeEnum.toLowerCase()};size=$size;unique=$unique;unix.group=$group;unix.mode=$mode;unix.owner=$owner;unix.uid=$uid;unix.gid=$gid'; +} + +enum FTPEntryType { FILE, DIR, LINK, UNKNOWN } + +extension FtpEntryTypeEnum on FTPEntryType { + String get describeEnum => + this.toString().substring(this.toString().indexOf('.') + 1); +} diff --git a/lib/core/utils/ftpconnect/src/ftp_exceptions.dart b/lib/core/utils/ftpconnect/src/ftp_exceptions.dart new file mode 100644 index 000000000..59368b2ba --- /dev/null +++ b/lib/core/utils/ftpconnect/src/ftp_exceptions.dart @@ -0,0 +1,11 @@ +class FTPConnectException implements Exception { + final String message; + final String? response; + + FTPConnectException(this.message, [this.response]); + + @override + String toString() { + return 'FTPConnectException: $message (Response: $response)'; + } +} diff --git a/lib/core/utils/ftpconnect/src/ftp_reply.dart b/lib/core/utils/ftpconnect/src/ftp_reply.dart new file mode 100644 index 000000000..c4538f176 --- /dev/null +++ b/lib/core/utils/ftpconnect/src/ftp_reply.dart @@ -0,0 +1,24 @@ +import 'dart:core'; + +class FTPReply { + final int _code; + final String _messages; + + FTPReply(this._code, this._messages); + + get code => this._code; + + get message => this._messages; + + bool isSuccessCode() { + int aux = this._code - 200; + return aux >= 0 && aux < 100; + } + + @override + String toString() { + StringBuffer buffer = new StringBuffer(); + buffer.write("FTPReply = [code= $_code, message= $_messages]"); + return buffer.toString(); + } +} diff --git a/lib/core/utils/ftpconnect/src/ftp_socket.dart b/lib/core/utils/ftpconnect/src/ftp_socket.dart new file mode 100644 index 000000000..cdb28c7be --- /dev/null +++ b/lib/core/utils/ftpconnect/src/ftp_socket.dart @@ -0,0 +1,221 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import '../ftpconnect.dart'; +import 'ftp_reply.dart'; + +class FTPSocket { + final String host; + final int port; + final Logger logger; + final int timeout; + final SecurityType securityType; + late RawSocket _socket; + TransferMode transferMode = TransferMode.passive; + TransferType _transferType = TransferType.auto; + ListCommand listCommand = ListCommand.MLSD; + bool supportIPV6 = false; + + FTPSocket(this.host, this.port, this.securityType, this.logger, this.timeout); + + /// Set current transfer type of socket + /// + /// Supported types are: [TransferType.auto], [TransferType.ascii], [TransferType.binary], + TransferType get transferType => _transferType; + + /// Read the FTP Server response from the Stream + /// + /// Blocks until data is received! + Future readResponse() async { + StringBuffer res = StringBuffer(); + await Future.doWhile(() async { + bool dataReceivedSuccessfully = false; + + //this is used to read all data for specific command line + while (_socket.available() > 0) { + res.write(String.fromCharCodes(_socket.read()!).trim()); + dataReceivedSuccessfully = true; + } + if (dataReceivedSuccessfully) return false; + + await Future.delayed(Duration(milliseconds: 300)); + return true; + }).timeout(Duration(seconds: timeout), onTimeout: () { + throw FTPConnectException('Timeout reached for Receiving response !'); + }); + + String r = res.toString(); + if (r.startsWith("\n")) r = r.replaceFirst("\n", ""); + + if (r.length < 3) throw FTPConnectException("Illegal Reply Exception", r); + + int? code; + List lines = r.split('\n'); + //get last code + String? line; + for (line in lines) { + if (line.length >= 3) code = int.tryParse(line.substring(0, 3)) ?? code; + } + //multiline response + if (line != null && line.length >= 4 && line[3] == '-') + return await readResponse(); + + if (code == null) throw FTPConnectException("Illegal Reply Exception", r); + + FTPReply reply = FTPReply(code, r); + logger.log('< ${reply.toString()}'); + return reply; + } + + /// Send a command [cmd] to the FTP Server + /// if [waitResponse] the function waits for the reply, other wise return '' + Future sendCommand(String cmd) { + logger.log('> $cmd'); + _socket.write(Utf8Codec().encode('$cmd\r\n')); + + return readResponse(); + } + + /// Send a command [cmd] to the FTP Server + /// if [waitResponse] the function waits for the reply, other wise return '' + void sendCommandWithoutWaitingResponse(String cmd) async { + logger.log('> $cmd'); + _socket.write(Utf8Codec().encode('$cmd\r\n')); + } + + /// Connect to the FTP Server and Login with [user] and [pass] + Future connect(String user, String pass, {String? account}) async { + logger.log('Connecting...'); + + final timeout = Duration(seconds: this.timeout); + + try { + // FTPS starts secure + if (securityType == SecurityType.FTPS) { + _socket = await RawSecureSocket.connect( + host, + port, + timeout: timeout, + onBadCertificate: (certificate) => true, + ); + } else { + _socket = await RawSocket.connect( + host, + port, + timeout: timeout, + ); + } + } catch (e) { + throw FTPConnectException( + 'Could not connect to $host ($port)', e.toString()); + } + + logger.log('Connection established, waiting for welcome message...'); + await readResponse(); + + // FTPES needs to be upgraded prior to getting a welcome + if (securityType == SecurityType.FTPES) { + FTPReply lResp = await sendCommand('AUTH TLS'); + if (!lResp.isSuccessCode()) { + lResp = await sendCommand('AUTH SSL'); + if (!lResp.isSuccessCode()) { + throw FTPConnectException( + 'FTPES cannot be applied: the server refused both AUTH TLS and AUTH SSL commands', + lResp.message); + } + } + + _socket = await RawSecureSocket.secure(_socket, + onBadCertificate: (certificate) => true); + } + + if ([SecurityType.FTPES, SecurityType.FTPS].contains(securityType)) { + await sendCommand('PBSZ 0'); + await sendCommand('PROT P'); + } + + // Send Username + FTPReply lResp = await sendCommand('USER $user'); + + //password required + if (lResp.code == 331) { + lResp = await sendCommand('PASS $pass'); + if (lResp.code == 332) { + if (account == null) throw FTPConnectException('Account required'); + lResp = await sendCommand('ACCT $account'); + if (!lResp.isSuccessCode()) { + throw FTPConnectException('Wrong Account', lResp.message); + } + } else if (!lResp.isSuccessCode()) { + throw FTPConnectException('Wrong Username/password', lResp.message); + } + //account required + } else if (lResp.code == 332) { + if (account == null) throw FTPConnectException('Account required'); + lResp = await sendCommand('ACCT $account'); + if (!lResp.isSuccessCode()) { + throw FTPConnectException('Wrong Account', lResp.message); + } + } else if (!lResp.isSuccessCode()) { + throw FTPConnectException('Wrong username $user', lResp.message); + } + + logger.log('Connected!'); + return true; + } + + Future openDataTransferChannel() async { + FTPReply res = FTPReply(200, ""); + if (transferMode == TransferMode.active) { + //todo later + } else { + res = await sendCommand(supportIPV6 ? 'EPSV' : 'PASV'); + if (!res.isSuccessCode()) { + throw FTPConnectException('Could not start Passive Mode', res.message); + } + } + + return res; + } + + /// Set the Transfer mode on [socket] to [mode] + Future setTransferType(TransferType pTransferType) async { + //if we already in the same transfer type we do nothing + if (_transferType == pTransferType) return; + switch (pTransferType) { + case TransferType.auto: + // Set to ASCII mode + await sendCommand('TYPE A'); + break; + case TransferType.ascii: + // Set to ASCII mode + await sendCommand('TYPE A'); + break; + case TransferType.binary: + // Set to BINARY mode + await sendCommand('TYPE I'); + break; + default: + break; + } + _transferType = pTransferType; + } + + // Disconnect from the FTP Server + Future disconnect() async { + logger.log('Disconnecting...'); + + try { + await sendCommand('QUIT'); + } catch (ignored) { + // Ignore + } finally { + await _socket.close(); + _socket.shutdown(SocketDirection.both); + } + + logger.log('Disconnected!'); + return true; + } +} diff --git a/lib/core/utils/ftpconnect/src/ftpconnect_base.dart b/lib/core/utils/ftpconnect/src/ftpconnect_base.dart new file mode 100644 index 000000000..f7e8f0085 --- /dev/null +++ b/lib/core/utils/ftpconnect/src/ftpconnect_base.dart @@ -0,0 +1,307 @@ +import 'dart:io'; + +import '../ftpconnect.dart'; +import 'package:path/path.dart'; + +import 'commands/directory.dart'; +import 'commands/file.dart'; +import 'ftp_reply.dart'; +import 'ftp_socket.dart'; +import 'utils.dart'; + +class FTPConnect { + final String _user; + final String _pass; + late FTPSocket _socket; + + /// Create a FTP Client instance + /// + /// [host]: Hostname or IP Address + /// [port]: Port number (Defaults to 21 for FTP and FTPES, 990 for FTPS) + /// [user]: Username (Defaults to anonymous) + /// [pass]: Password if not anonymous login + /// [debug]: Enable Debug Logging + /// [timeout]: Timeout in seconds to wait for responses + FTPConnect( + String host, { + int? port, + String user = 'anonymous', + String pass = '', + bool showLog = false, + SecurityType securityType = SecurityType.FTP, + Logger? logger, + int timeout = 30, + }) : _user = user, + _pass = pass { + port ??= securityType == SecurityType.FTPS ? 990 : 21; + _socket = FTPSocket( + host, + port, + securityType, + logger != null ? logger : Logger(isEnabled: showLog), + timeout, + ); + } + + set transferMode(TransferMode pTransferMode) { + _socket.transferMode = pTransferMode; + } + + set listCommand(ListCommand pListCommand) { + _socket.listCommand = pListCommand; + } + + set supportIPV6(bool pSupportIPV6) => _socket.supportIPV6 = pSupportIPV6; + + /// Set current transfer type of connection + /// + /// Supported types are: [TransferType.auto], [TransferType.ascii], [TransferType.binary], + Future setTransferType(TransferType pTransferType) async { + if (_socket.transferType == pTransferType) return; + await _socket.setTransferType(pTransferType); + } + + /// Connect to the FTP Server + /// return true if we are connected successfully + Future connect() => _socket.connect(_user, _pass); + + /// Disconnect from the FTP Server + /// return true if we are disconnected successfully + Future disconnect() => _socket.disconnect(); + + Future sendCustomCommand(String pCmd) => _socket.sendCommand(pCmd); + + /// Upload the File [fFile] to the current directory + Future uploadFile( + File fFile, { + String sRemoteName = '', + FileProgress? onProgress, + }) { + return FTPFile(_socket).upload( + fFile, + remoteName: sRemoteName, + onProgress: onProgress, + ); + } + + /// Download the Remote File [sRemoteName] to the local File [fFile] + Future downloadFile( + String? sRemoteName, + File fFile, { + FileProgress? onProgress, + }) { + return FTPFile(_socket) + .download(sRemoteName, fFile, onProgress: onProgress); + } + + /// Create a new Directory with the Name of [sDirectory] in the current directory. + /// + /// Returns `true` if the directory was created successfully + /// Returns `false` if the directory could not be created or already exists + Future makeDirectory(String sDirectory) { + return FTPDirectory(_socket).makeDirectory(sDirectory); + } + + /// Deletes the Directory with the Name of [sDirectory] in the current directory. + /// + /// Returns `true` if the directory was deleted successfully + /// Returns `false` if the directory could not be deleted or does not nexist + Future deleteEmptyDirectory(String? sDirectory) { + return FTPDirectory(_socket).deleteEmptyDirectory(sDirectory); + } + + /// Deletes the Directory with the Name of [sDirectory] in the current directory. + /// + /// Returns `true` if the directory was deleted successfully + /// Returns `false` if the directory could not be deleted or does not nexist + /// THIS USEFUL TO DELETE NON EMPTY DIRECTORY + Future deleteDirectory(String sDirectory) async { + String currentDir = await this.currentDirectory(); + if (!await this.changeDirectory(sDirectory)) { + throw FTPConnectException("Couldn't change directory to $sDirectory"); + } + List dirContent = await this.listDirectoryContent(); + await Future.forEach(dirContent, (FTPEntry entry) async { + if (entry.type == FTPEntryType.FILE) { + if (!await deleteFile(entry.name)) { + throw FTPConnectException("Couldn't delete file ${entry.name}"); + } + } else { + if (!await deleteDirectory(entry.name)) { + throw FTPConnectException("Couldn't delete folder ${entry.name}"); + } + } + }); + await this.changeDirectory(currentDir); + return await deleteEmptyDirectory(sDirectory); + } + + /// Change into the Directory with the Name of [sDirectory] within the current directory. + /// + /// Use `..` to navigate back + /// Returns `true` if the directory was changed successfully + /// Returns `false` if the directory could not be changed (does not exist, no permissions or another error) + Future changeDirectory(String? sDirectory) { + return FTPDirectory(_socket).changeDirectory(sDirectory); + } + + /// Returns the current directory + Future currentDirectory() { + return FTPDirectory(_socket).currentDirectory(); + } + + /// Returns the content of the current directory + /// [cmd] refer to the used command for the server, there is servers working + /// with MLSD and other with LIST + Future> listDirectoryContent() { + return FTPDirectory(_socket).directoryContent(); + } + + /// Returns the content names of the current directory + /// [cmd] refer to the used command for the server, there is servers working + /// with MLSD and other with LIST for detailed content + Future> listDirectoryContentOnlyNames() { + return FTPDirectory(_socket).directoryContentNames(); + } + + /// Rename a file (or directory) from [sOldName] to [sNewName] + Future rename(String sOldName, String sNewName) { + return FTPFile(_socket).rename(sOldName, sNewName); + } + + /// Delete the file [sFilename] from the server + Future deleteFile(String? sFilename) { + return FTPFile(_socket).delete(sFilename); + } + + /// check the existence of the file [sFilename] from the server + Future existFile(String sFilename) { + return FTPFile(_socket).exist(sFilename); + } + + /// returns the file [sFilename] size from server, + /// returns -1 if file does not exist + Future sizeFile(String sFilename) { + return FTPFile(_socket).size(sFilename); + } + + /// Upload the File [fileToUpload] to the current directory + /// if [pRemoteName] is not setted the remote file will take take the same local name + /// [pRetryCount] number of attempts + /// + /// this strategy can be used when we don't need to go step by step + /// (connect -> upload -> disconnect) or there is a need for a number of attemps + /// in case of a poor connexion for example + Future uploadFileWithRetry( + File fileToUpload, { + String pRemoteName = '', + int pRetryCount = 1, + FileProgress? onProgress, + }) { + Future uploadFileRetry() async { + bool res = await this.uploadFile( + fileToUpload, + sRemoteName: pRemoteName, + onProgress: onProgress, + ); + return res; + } + + return Utils.retryAction(() => uploadFileRetry(), pRetryCount); + } + + /// Download the Remote File [pRemoteName] to the local File [pLocalFile] + /// [pRetryCount] number of attempts + /// + /// this strategy can be used when we don't need to go step by step + /// (connect -> download -> disconnect) or there is a need for a number of attempts + /// in case of a poor connexion for example + Future downloadFileWithRetry( + String pRemoteName, + File pLocalFile, { + int pRetryCount = 1, + FileProgress? onProgress, + }) { + Future downloadFileRetry() async { + bool res = await this.downloadFile( + pRemoteName, + pLocalFile, + onProgress: onProgress, + ); + return res; + } + + return Utils.retryAction(() => downloadFileRetry(), pRetryCount); + } + + /// Download the Remote Directory [pRemoteDir] to the local File [pLocalDir] + /// [pRetryCount] number of attempts + Future downloadDirectory(String pRemoteDir, Directory pLocalDir, + {int pRetryCount = 1}) { + Future downloadDir(String? pRemoteDir, Directory pLocalDir) async { + await pLocalDir.create(recursive: true); + + //read remote directory content + if (!await this.changeDirectory(pRemoteDir)) { + throw FTPConnectException('Cannot download directory', + '$pRemoteDir not found or inaccessible !'); + } + List dirContent = await this.listDirectoryContent(); + await Future.forEach(dirContent, (FTPEntry entry) async { + if (entry.type == FTPEntryType.FILE) { + File localFile = File(join(pLocalDir.path, entry.name)); + await downloadFile(entry.name, localFile); + } else if (entry.type == FTPEntryType.DIR) { + //create a local directory + var localDir = await Directory(join(pLocalDir.path, entry.name)) + .create(recursive: true); + await downloadDir(entry.name, localDir); + //back to current folder + await this.changeDirectory('..'); + } + }); + return true; + } + + Future downloadDirRetry() async { + bool res = await downloadDir(pRemoteDir, pLocalDir); + return res; + } + + return Utils.retryAction(() => downloadDirRetry(), pRetryCount); + } + + /// check the existence of the Directory with the Name of [pDirectory]. + /// + /// Returns `true` if the directory was changed successfully + /// Returns `false` if the directory could not be changed (does not exist, no permissions or another error) + Future checkFolderExistence(String pDirectory) { + return this.changeDirectory(pDirectory); + } + + /// Create a new Directory with the Name of [pDirectory] in the current directory if it does not exist. + /// + /// Returns `true` if the directory exists or was created successfully + /// Returns `false` if the directory not found and could not be created + Future createFolderIfNotExist(String pDirectory) async { + if (!await checkFolderExistence(pDirectory)) { + return this.makeDirectory(pDirectory); + } + return true; + } +} + +///Note that [LIST] and [MLSD] return content detailed +///BUT [NLST] return only dir/file names inside the given directory +enum ListCommand { NLST, LIST, MLSD } + +enum TransferType { auto, ascii, binary } + +enum TransferMode { active, passive } + +enum SecurityType { FTP, FTPS, FTPES } + +extension CommandListTypeEnum on ListCommand { + String get describeEnum => + this.toString().substring(this.toString().indexOf('.') + 1); +} diff --git a/lib/core/utils/ftpconnect/src/logger.dart b/lib/core/utils/ftpconnect/src/logger.dart new file mode 100644 index 000000000..ce4a1ad6d --- /dev/null +++ b/lib/core/utils/ftpconnect/src/logger.dart @@ -0,0 +1,13 @@ +class Logger { + final bool isEnabled; + + Logger({this.isEnabled = false}); + + void log(String pMessage) { + if (isEnabled) _printLog(pMessage); + } + + void _printLog(String pMessage) { + print('[${DateTime.now().toString()}] $pMessage'); + } +} diff --git a/lib/core/utils/ftpconnect/src/utils.dart b/lib/core/utils/ftpconnect/src/utils.dart new file mode 100644 index 000000000..6fbd41baa --- /dev/null +++ b/lib/core/utils/ftpconnect/src/utils.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +class Utils { + Utils._(); + + static int parsePort(String response, bool isIPV6) { + return isIPV6 ? parsePortEPSV(response) : parsePortPASV(response); + } + + /// Parse the Passive Mode Port from the Servers [sResponse] + /// port format (|||xxxxx|) + static int parsePortEPSV(String sResponse) { + int iParOpen = sResponse.indexOf('('); + int iParClose = sResponse.indexOf(')'); + + if (iParClose > -1 && iParOpen > -1) { + sResponse = sResponse.substring(iParOpen + 4, iParClose - 1); + } + return int.parse(sResponse); + } + + /// Parse the Passive Mode Port from the Servers [sResponse] + /// format 227 Entering Passive Mode (192,168,8,36,8,75). + static int parsePortPASV(String sResponse) { + int iParOpen = sResponse.indexOf('('); + int iParClose = sResponse.indexOf(')'); + + String sParameters = sResponse.substring(iParOpen + 1, iParClose); + List lstParameters = sParameters.split(','); + + int iPort1 = int.parse(lstParameters[lstParameters.length - 2]); + int iPort2 = int.parse(lstParameters[lstParameters.length - 1]); + + return (iPort1 * 256) + iPort2; + } + + ///retry a function [retryCount] times, until exceed [retryCount] or execute the function successfully + ///Return true if the future executed successfully , false other wises + static Future retryAction(FutureOr action(), retryCount) async { + int lAttempts = 1; + bool result = true; + await Future.doWhile(() async { + try { + result = await action(); + //if there is no exception we exit the loop (return false to exit) + return false; + } catch (e) { + if (lAttempts++ >= retryCount) { + throw e; + } + } + //return true to loop again + return true; + }); + return result; + } +}