diff --git a/FtpServer.cpp b/FtpServer.cpp index 65a2115..895a810 100644 --- a/FtpServer.cpp +++ b/FtpServer.cpp @@ -50,6 +50,7 @@ #include #include #include +#include // Implementations for 8.3 helpers (only for SD on AVR) #if (STORAGE_TYPE == STORAGE_SD) @@ -289,7 +290,9 @@ void FtpServer::begin( const char * _welcomeMessage ) { void FtpServer::end() { if(client.connected()) { - disconnectClient(); + disconnectClient(); // -> abortTransfer -> finishCustom if a custom transfer is live + } else if (transferStage == FTP_Custom) { // no client to disconnect, but a custom transfer is still + finishCustom( CustomTransfer::TR_ABORTED ); // in flight: free it so onEnd runs on this server-side stop too } #if FTP_SERVER_NETWORK_TYPE == NETWORK_ESP32 // && !defined(ARDUINO_ARCH_RP2040) @@ -441,13 +444,32 @@ uint8_t FtpServer::handleFTP() { } else if (transferStage == FTP_Mlsd) // MLSD listing { if (!doMlsd()) { - transferStage = FTP_Close; } - } else if (cmdStage > FTP_Client + } else if (transferStage == FTP_Custom) // caller-driven cooperative transfer + { + if (!doCustom()) { + transferStage = FTP_Close; + } + } + + // Out of the chain above, whose tail this was: a running transfer always took its own + // branch, so the deadline was never reached — and doRetrieve() now waits a stalled peer + // out instead of aborting, leaving nothing else to end it. RETR and the listings both + // refresh this deadline as they send, so it ends only what stopped sending; STOR does + // not refresh it and stays out of scope rather than die mid-progress. + const bool in_transfer = (transferStage == FTP_Retrieve || transferStage == FTP_List + || transferStage == FTP_Nlst || transferStage == FTP_Mlsd); + if (cmdStage > FTP_Client && (transferStage == FTP_Close || in_transfer) && !((int32_t) (millisEndConnection - millis()) > 0)) { - DEBUG_PRINTLN(F("530 Timeout")); - client.println(F("530 Timeout")); + DEBUG_PRINTLN(F("Timeout")); + if (in_transfer) { + // NOT closeTransfer(): that answers 226. abortTransfer() replies 426 and fires + // FTP_TRANSFER_ERROR, which releases what the app took. + abortTransfer(); + } else { + client.println(F("530 Timeout")); + } millisDelay = millis() + 200; // delay of 200 ms cmdStage = FTP_Stop; } @@ -508,6 +530,91 @@ void FtpServer::disconnectClient() } } +// --- FtpResponse: the narrow capability facade handed to a command hook (see FtpServer.h). +// Each method acts on the owning server's internals via friendship. --- +void FtpResponse::reply( const char * line ) +{ + server_.client.println( line ); + server_._replied = true; // marks the command handled (built-in skipped) +} + +void FtpResponse::rewriteCommand( const char * cmd, const char * param ) +{ + strncpy( server_.command, cmd, sizeof( server_.command ) - 1 ); + server_.command[ sizeof( server_.command ) - 1 ] = '\0'; + size_t n = param ? strnlen( param, sizeof( server_.cmdLine ) - 1 ) : 0; + memmove( server_.cmdLine, param ? param : "", n ); // memmove: param may point into cmdLine + server_.cmdLine[ n ] = '\0'; + server_.parameter = server_.cmdLine; +} + +bool FtpResponse::isAuthenticated() const +{ + return server_.cmdStage == FTP_Cmd; +} + +void FtpResponse::setAuthenticated( bool authenticated ) +{ + server_.cmdStage = authenticated ? FTP_Cmd : FTP_User; +} + +bool FtpResponse::beginCustomTransfer( const CustomTransfer * xfer, void * ctx ) +{ + if( ! server_.dataConnect( true ) ) // open data connection + send "150"; false on failure + return false; + server_._xfer = xfer; + server_._customCtx = ctx; + server_.transferStage = FTP_Custom; + server_.bytesTransfered = 0; // progress accumulator, same one the built-in uses + if( server_._transferCallback ) // mirror RETR: announce the transfer start (size unknown → 0) + server_._transferCallback( FTP_DOWNLOAD_START, xfer->name, 0 ); + // Mark THIS command handled. _replied is per-command (reset before each hook call), so a + // later command (e.g. ABOR) arriving while the transfer is still running is NOT swallowed — + // it falls through to its built-in. Keying off transferStage instead would block every + // command for the whole transfer. + server_._replied = true; + return true; +} + +// Push one chunk of the active custom transfer; false ends it (finishCustom already ran). +bool FtpServer::doCustom() +{ + int r = _xfer->sendChunk( _customCtx, data ); + if( r > 0 ) // bytes written this tick — report progress, continue + { + bytesTransfered += r; + if( FtpServer::_transferCallback ) + FtpServer::_transferCallback( FTP_DOWNLOAD, _xfer->name, bytesTransfered ); + return true; + } + if( r == 0 ) // yielded (nothing to send this tick); no progress + return true; + finishCustom( r == -1 ? CustomTransfer::TR_DONE : CustomTransfer::TR_ABORTED ); + return false; +} + +// End a custom transfer exactly once: onEnd() (caller cleanup + optional custom final line), +// then the final response (custom or default), close the data connection, clear state. +void FtpServer::finishCustom( CustomTransfer::TransferResult result ) +{ + const char * line = ( _xfer && _xfer->onEnd ) ? _xfer->onEnd( _customCtx, result ) : nullptr; + +#if defined(ESP8266) || defined(ESP32) + data.flush(); + delay( 20 ); // grace period to let TCP finish sending +#endif + data.stop(); + + client.println( line && *line ? line + : ( result == CustomTransfer::TR_DONE ? "226 Transfer complete" : "426 Transfer aborted" ) ); + if( FtpServer::_transferCallback ) // mirror RETR: report the terminal outcome + total bytes + FtpServer::_transferCallback( result == CustomTransfer::TR_DONE ? FTP_TRANSFER_STOP : FTP_TRANSFER_ERROR, + _xfer ? _xfer->name : nullptr, bytesTransfered ); + _xfer = nullptr; + _customCtx = nullptr; + transferStage = FTP_Close; +} + bool FtpServer::processCommand() { /////////////////////////////////////// @@ -520,6 +627,22 @@ bool FtpServer::processCommand() DEBUG_PRINT(F("Command is: ")); DEBUG_PRINTLN(command); + // Command hook: runs before the built-in dispatch. reply() marks the command as handled (the + // built-in is skipped); rewriteCommand() changes which command runs; doing nothing lets the + // original run. + _replied = false; + if( _commandHandler ) + { + FtpResponse res( *this ); + _commandHandler( res, command, parameter ); + } + + if( _replied ) + { + // _commandHandler processed this command + return true; + } + // // USER - User Identity // @@ -867,6 +990,10 @@ bool FtpServer::processCommand() DEBUG_PRINTLN(F("Dir opened!!")); nbMatch = 0; + // Reset here rather than where a listing ends: a client that drops the data + // connection mid-entry ends one through neither closeTransfer() nor abortTransfer(), + // and the entry left pending would open the next listing. + listLineLen = listLineSent = 0; if( CommandIs( "LIST" )) transferStage = FTP_List; else if( CommandIs( "NLST" )) @@ -1393,9 +1520,7 @@ bool FtpServer::dataConnected() { if( data.connected()) return true; - data.stop(); - client.println(F("426 Data connection closed. Transfer aborted") ); - transferStage = FTP_Close; + abortTransfer(F("426 Data connection closed. Transfer aborted")); return false; } @@ -1496,8 +1621,8 @@ bool FtpServer::doRetrieve() // Handle resume if REST was used if (restartPos > 0) { if (!file.seek(restartPos)) { - client.println(F("450 Cannot seek to restart position.")); - closeTransfer(); + DEBUG_PRINTLN(F("ERROR: cannot seek to restart position")); + abortTransfer(F("450 Cannot seek to restart position.")); return false; } bytesTransfered = restartPos; // Adjust the transferred bytes @@ -1525,31 +1650,33 @@ bool FtpServer::doRetrieve() { // write() may not send everything in one call on some clients; capture return int32_t written = 0; - written = data.write( buf, nb ); + written = writeData( (const uint8_t*) buf, nb ); DEBUG_PRINT(F("NB --> ")); DEBUG_PRINTLN(nb); DEBUG_PRINT(F("WRITTEN --> ")); DEBUG_PRINTLN(written); - if (written <= 0) { - DEBUG_PRINTLN(F("ERROR: data.write returned <= 0")); - closeTransfer(); - return false; - } - // If partial write, try to send the remainder (best-effort) - if (written < nb) { + if (written > 0 && written < nb) { int16_t remaining = nb - written; DEBUG_PRINT(F("Partial write, attempting remainder -> ")); DEBUG_PRINTLN(remaining); const uint8_t* p = (const uint8_t*)buf + written; - int32_t more = data.write(p, remaining); + int32_t more = writeData(p, remaining); DEBUG_PRINT(F("MORE WRITTEN -> ")); DEBUG_PRINTLN(more); if (more > 0) written += more; } + // file.read() advanced the cursor by the full nb, so whatever went unsent must be re-read + // next round — otherwise those bytes vanish from the middle of the stream, silently. + if (written < nb && !file.seek(bytesTransfered + written)) { + DEBUG_PRINTLN(F("ERROR: cannot rewind after a short write")); + abortTransfer(); // the unsent bytes are unrecoverable — this is not a completed transfer + return false; + } + // Try to flush the socket where available (ESP-specific) #if defined(ESP8266) || defined(ESP32) data.flush(); @@ -1566,15 +1693,17 @@ bool FtpServer::doRetrieve() DEBUG_PRINT(F("DATA CONNECTED AFTER WRITE -> ")); DEBUG_PRINTLN(data.connected() ? 1 : 0); + // Reachable only with bytes still to send, so the peer left mid-file: the transfer failed. if (!data.connected()) { DEBUG_PRINTLN(F("Data socket closed by peer after write")); - closeTransfer(); + abortTransfer(); return false; } bytesTransfered += written; - if (FtpServer::_transferCallback) { + // Invoke callback on real progress: a stalled round must not look like a moving one to a watching app. + if (written > 0 && FtpServer::_transferCallback) { FtpServer::_transferCallback(FTP_DOWNLOAD, getFileName(&file).c_str(), bytesTransfered); } @@ -1607,8 +1736,9 @@ bool FtpServer::doStore() DEBUG_PRINT(F("No data received after ")); DEBUG_PRINT(waited); DEBUG_PRINTLN(F(" ms")); - // Decide to close transfer to avoid infinite loop and client timeout - closeTransfer(); + // A peer that finished a STOR closes the data connection; one still connected and + // silent has stalled, so answering 226 would call an unfinished upload complete. + abortTransfer(); return false; } // else continue and read available data below @@ -1662,65 +1792,22 @@ bool FtpServer::doStore() if( nb < 0 || rc == nb ) { return true; } - client.println(F("552 Probably insufficient storage space") ); - file.close(); - data.stop(); + + abortTransfer(F("552 Probably insufficient storage space")); return false; } -void generateFileLine(FTP_CLIENT_NETWORK_CLASS* data, bool isDirectory, const char* fn, long fz, const char* time, const char* user, bool writeFilename = true) { - if( isDirectory ) { - // data->print( F("+/,\t") ); - // DEBUG_PRINT(F("+/,\t")); - - data->print( F("drwxrwsr-x\t2\t")); - data->print( user ); - data->print( F("\t") ); - data->print( long( 4096 ) ); - data->print( F("\t") ); - - DEBUG_PRINT( F("drwxrwsr-x\t2\t") ); - DEBUG_PRINT( user ); - DEBUG_PRINT( F("\t") ); - - DEBUG_PRINT( long( 4096 ) ); - DEBUG_PRINT( F("\t") ); - - data->print(time); - DEBUG_PRINT(time); - - data->print( F("\t") ); - if (writeFilename) data->println( fn ); - - DEBUG_PRINT( F("\t") ); - if (writeFilename) DEBUG_PRINTLN( fn ); - - } else { -// data.print( F("+r,s") ); -// DEBUG_PRINT(F("+r,s")); - - data->print( F("-rw-rw-r--\t1\t") ); - data->print( user ); - data->print( F("\t") ); - data->print( fz ); - data->print( F("\t") ); - - DEBUG_PRINT( F("-rw-rw-r--\t1\t") ); - DEBUG_PRINT( user ); - DEBUG_PRINT( F("\t") ); - DEBUG_PRINT( fz ); - DEBUG_PRINT( F("\t") ); - - data->print(time); - DEBUG_PRINT(time); - - data->print( F("\t") ); - if (writeFilename) data->println( fn ); - - DEBUG_PRINT( F("\t") ); - if (writeFilename) DEBUG_PRINTLN( fn ); - } - +// Renders one LIST entry into `out` and returns the length the whole line needs, which may +// exceed `outSize` — the caller decides what a line too long for its buffer becomes, and it +// cannot decide that from a length already clamped to the buffer. Building the line before +// any of it is sent is what lets a caller resume a partial write: nine separate print() +// calls could not, and each of them burns the socket's whole write budget again when the +// peer's window is shut. +size_t generateFileLine(char* out, size_t outSize, bool isDirectory, const char* fn, long fz, const char* time, const char* user) { + const int n = snprintf(out, outSize, "%s\t%s\t%ld\t%s\t%s\r\n", + isDirectory ? "drwxrwsr-x\t2" : "-rw-rw-r--\t1", + user, isDirectory ? 4096L : fz, time, fn); + return n < 0 ? 0 : (size_t) n; } #if defined(ESP32) || defined(ESP8266) || defined(ARDUINO_ARCH_RP2040) @@ -1772,11 +1859,69 @@ String makeDateTimeStrList(time_t ft, bool dateContracted = false) } // https://files.stairways.com/other/ftp-list-specs-info.txt -void generateFileLine(FTP_CLIENT_NETWORK_CLASS* data, bool isDirectory, const char* fn, long fz, time_t time, const char* user, bool writeFilename = true) { - generateFileLine(data, isDirectory, fn, fz, makeDateTimeStrList(time).c_str(), user, writeFilename); +size_t generateFileLine(char* out, size_t outSize, bool isDirectory, const char* fn, long fz, time_t time, const char* user) { + return generateFileLine(out, outSize, isDirectory, fn, fz, makeDateTimeStrList(time).c_str(), user); } #endif +// Renders one listing entry into listLine. Callers hand over the values their storage +// backend exposes; the wire format is the backend-independent part. +// Takes what snprintf() reported and returns the length to send. A line longer than the buffer +// is truncated by snprintf without its CRLF, and a listing entry with no line ending merges into +// the next one at the client, so the ending is restored over the last two bytes. +uint16_t FtpServer::finishListLine(int rendered) +{ + listLineSent = 0; + if( rendered <= 0 ) return 0; + if( (size_t) rendered < sizeof( listLine )) return (uint16_t) rendered; + listLine[ sizeof( listLine ) - 3 ] = '\r'; + listLine[ sizeof( listLine ) - 2 ] = '\n'; + listLine[ sizeof( listLine ) - 1 ] = '\0'; + return (uint16_t) ( sizeof( listLine ) - 1 ); +} + +// Renders one listing entry into listLine. Callers hand over the values their storage backend +// exposes; the wire format is the backend-independent part. +void FtpServer::buildListLine(bool isNlst, bool isDirectory, const char* fn, long fz, const char* time) +{ + const int n = isNlst ? snprintf( listLine, sizeof( listLine ), "%s\r\n", fn ) + : (int) generateFileLine( listLine, sizeof( listLine ), isDirectory, fn, fz, time, this->user ); + listLineLen = finishListLine( n ); + DEBUG_PRINT( listLine ); +} + +void FtpServer::buildListLine(bool isNlst, bool isDirectory, const char* fn, long fz, time_t time) +{ + buildListLine( isNlst, isDirectory, fn, fz, makeDateTimeStrList( time ).c_str()); +} + +void FtpServer::buildMlsdLine(bool isDirectory, const char* dtStr, long fz, const char* fn) +{ + const int n = snprintf( listLine, sizeof( listLine ), "Type=%s;Modify=%s;Size=%ld; %s\r\n", + isDirectory ? "dir" : "file", dtStr, fz, fn ); + listLineLen = finishListLine( n ); + DEBUG_PRINT( listLine ); +} + +size_t FtpServer::writeData(const uint8_t* p, size_t len) +{ + const size_t n = data.write( p, len ); + if( n > 0 ) millisEndConnection = millis() + 1000L * FTP_TIME_OUT; + return n; +} + +bool FtpServer::sendListLine() +{ + if( listLineLen == 0 ) return true; + listLineSent += (uint16_t) writeData((const uint8_t*) listLine + listLineSent, + listLineLen - listLineSent ); + if( listLineSent < listLineLen ) return false; + listLineLen = 0; + listLineSent = 0; + nbMatch ++; + return true; +} + bool FtpServer::doList() { if( ! dataConnected()) @@ -1787,6 +1932,10 @@ bool FtpServer::doList() return false; } + // An entry already rendered owns this round: its directory slot is gone, so the + // remainder has to go out before the cursor may move again. + if( listLineLen > 0 && ! sendListLine()) return true; + // Determine if current transfer is NLST (name list) so we only send filenames bool isNlst = (transferStage == FTP_Nlst); #if STORAGE_TYPE == STORAGE_SPIFFS @@ -1803,12 +1952,7 @@ bool FtpServer::doList() long fz = long( dir.fileSize()); if (fn[0]=='/') { fn.remove(0, fn.lastIndexOf("/")+1); } time_t time = dir.fileTime(); - if (isNlst) { - data.println(fn.c_str()); - DEBUG_PRINTLN(fn); - } else { - generateFileLine(&data, false, fn.c_str(), fz, time, this->user); - } + buildListLine( isNlst, false, fn.c_str(), fz, time ); #else long fz = long( fileDir.size()); const char* fnC = fileDir.name(); @@ -1820,16 +1964,11 @@ bool FtpServer::doList() } time_t time = fileDir.getLastWrite(); - if (isNlst) { - data.println(fn); - DEBUG_PRINTLN(fn); - } else { - generateFileLine(&data, false, fn, fz, time, this->user); - } + buildListLine( isNlst, false, fn, fz, time ); #endif - nbMatch ++; + sendListLine(); return true; } #elif STORAGE_TYPE == STORAGE_LITTLEFS || STORAGE_TYPE == STORAGE_SEEED_SD || STORAGE_TYPE == STORAGE_FFAT @@ -1876,21 +2015,14 @@ bool FtpServer::doList() // DEBUG_PRINT( F("\t") ); // DEBUG_PRINTLN( fileDir.name() ); #endif - if (isNlst) { - data.println(fn); - DEBUG_PRINTLN(fn); - } else { - #if defined(ESP8266) || defined(ARDUINO_ARCH_RP2040) - time_t time = dir.fileTime(); - generateFileLine(&data, dir.isDirectory(), fn, fz, time, this->user); - #elif defined(ESP32) - time_t time = fileDir.getLastWrite(); - generateFileLine(&data, fileDir.isDirectory(), fn, fz, time, this->user); - #else - generateFileLine(&data, fileDir.isDirectory(), fn, fz, "Jan 01 00:00", this->user); - #endif - } - nbMatch ++; + #if defined(ESP8266) || defined(ARDUINO_ARCH_RP2040) + buildListLine( isNlst, dir.isDirectory(), fn, fz, dir.fileTime()); + #elif defined(ESP32) + buildListLine( isNlst, fileDir.isDirectory(), fn, fz, fileDir.getLastWrite()); + #else + buildListLine( isNlst, fileDir.isDirectory(), fn, fz, "Jan 01 00:00" ); + #endif + sendListLine(); return true; } #elif STORAGE_TYPE == STORAGE_SD || STORAGE_TYPE == STORAGE_SD_MMC @@ -1903,22 +2035,12 @@ bool FtpServer::doList() #if STORAGE_TYPE == STORAGE_SD_MMC time_t time = fileDir.getLastWrite(); - if (isNlst) { - data.println(fn.c_str()); - DEBUG_PRINTLN(fn); - } else { - generateFileLine(&data, fileDir.isDirectory(), fn.c_str(), long( fileDir.size()), time, this->user); - } + buildListLine( isNlst, fileDir.isDirectory(), fn.c_str(), long( fileDir.size()), time ); #else - if (isNlst) { - data.println(fn.c_str()); - DEBUG_PRINTLN(fn); - } else { - generateFileLine(&data, fileDir.isDirectory(), fn.c_str(), long( fileDir.size()), "Jan 01 00:00", this->user); - } + buildListLine( isNlst, fileDir.isDirectory(), fn.c_str(), long( fileDir.size()), "Jan 01 00:00" ); #endif - nbMatch ++; + sendListLine(); return true; } @@ -1929,31 +2051,19 @@ bool FtpServer::doList() String fn = dir.fileName(); if (fn[0]=='/') { fn.remove(0, fn.lastIndexOf("/")+1); } - if (isNlst) { - data.println(fn.c_str()); - DEBUG_PRINTLN(fn); - } else { - generateFileLine(&data, dir.isDir(), fn.c_str(), long( dir.fileSize()), "Jan 01 00:00", this->user); - } + buildListLine( isNlst, dir.isDir(), fn.c_str(), long( dir.fileSize()), "Jan 01 00:00" ); - nbMatch ++; + sendListLine(); return true; } #else if( file.openNext( &dir, FTP_FILE_READ_ONLY )) { - // For storages using file.printName, only send name in NLST mode - if (isNlst) { - file.printName(&data); - data.println(); - } else { - generateFileLine(&data, file.isDir(), "", long( fileSize( file )), "Jan 01 00:00", this->user, false); - - file.printName( & data ); - data.println(); - } + char nameBuf[ FTP_CWD_SIZE ]; + file.getName( nameBuf, sizeof( nameBuf )); + buildListLine( isNlst, file.isDir(), nameBuf, long( fileSize( file )), "Jan 01 00:00" ); file.close(); - nbMatch ++; + sendListLine(); return true; } #endif @@ -1978,6 +2088,10 @@ bool FtpServer::doMlsd() DEBUG_PRINTLN(F("Not connected!!")); return false; } + // An entry already rendered owns this round: its directory slot is gone, so the + // remainder has to go out before the cursor may move again. + if( listLineLen > 0 && ! sendListLine()) return true; + DEBUG_PRINTLN(F("Connected!!")); #if STORAGE_TYPE == STORAGE_SPIFFS @@ -2017,21 +2131,8 @@ bool FtpServer::doMlsd() long fz = fileDir.size(); #endif - data.print( F("Type=") ); - - data.print( F("file") ); - data.print( F(";Modify=") ); data.print(dtStr);// data.print( makeDateTimeStr( dtStr, time, time) ); - data.print( F(";Size=") ); data.print( fz ); - data.print( F("; ") ); data.println( fn ); - - DEBUG_PRINT( F("Type=") ); - DEBUG_PRINT( F("file") ); - - DEBUG_PRINT( F(";Modify=") ); DEBUG_PRINT(dtStr); //DEBUG_PRINT( makeDateTimeStr( dtStr, time, time) ); - DEBUG_PRINT( F(";Size=") ); DEBUG_PRINT( fz ); - DEBUG_PRINT( F("; ") ); DEBUG_PRINTLN( fn ); - - nbMatch ++; + buildMlsdLine( false, dtStr, fz, fn.c_str()); + sendListLine(); return true; } #elif STORAGE_TYPE == STORAGE_LITTLEFS || STORAGE_TYPE == STORAGE_SEEED_SD || STORAGE_TYPE == STORAGE_FFAT @@ -2081,14 +2182,13 @@ bool FtpServer::doMlsd() #endif #if defined(ESP8266) || defined(ARDUINO_ARCH_RP2040) time_t time = dir.fileTime(); - generateFileLine(&data, dir.isDirectory(), fn, fz, time, this->user); + buildListLine( false, dir.isDirectory(), fn, fz, time ); #elif defined(ESP32) - time_t time = fileDir.getLastWrite(); - generateFileLine(&data, fileDir.isDirectory(), fn, fz, time, this->user); + buildListLine( false, fileDir.isDirectory(), fn, fz, fileDir.getLastWrite()); #else - generateFileLine(&data, fileDir.isDirectory(), fn, fz, "Jan 01 00:00", this->user); + buildListLine( false, fileDir.isDirectory(), fn, fz, "Jan 01 00:00" ); #endif - nbMatch ++; + sendListLine(); return true; } #elif STORAGE_TYPE == STORAGE_SD || STORAGE_TYPE == STORAGE_SD_MMC @@ -2111,21 +2211,8 @@ bool FtpServer::doMlsd() - data.print( F("Type=") ); - - data.print( ( fileDir.isDirectory() ? F("dir") : F("file")) ); - data.print( F(";Modify=") ); data.print(dtStr);// data.print( makeDateTimeStr( dtStr, time, time) ); - data.print( F(";Size=") ); data.print( fz ); - data.print( F("; ") ); data.println( fn ); - - DEBUG_PRINT( F("Type=") ); - DEBUG_PRINT( ( fileDir.isDirectory() ? F("dir") : F("file")) ); - - DEBUG_PRINT( F(";Modify=") ); DEBUG_PRINT(dtStr); //DEBUG_PRINT( makeDateTimeStr( dtStr, time, time) ); - DEBUG_PRINT( F(";Size=") ); DEBUG_PRINT( fz ); - DEBUG_PRINT( F("; ") ); DEBUG_PRINTLN( fn ); - - nbMatch ++; + buildMlsdLine( fileDir.isDirectory(), dtStr, fz, fn.c_str()); + sendListLine(); return true; } @@ -2133,11 +2220,10 @@ bool FtpServer::doMlsd() if( dir.nextFile()) { char dtStr[ 15 ]; - data.print( F("Type=") ); data.print( ( dir.isDir() ? F("dir") : F("file")) ); - data.print( F(";Modify=") ); data.print( makeDateTimeStr( dtStr, dir.fileModDate(), dir.fileModTime()) ); - data.print( F(";Size=") ); data.print( long( dir.fileSize()) ); - data.print( F("; ") ); data.println( dir.fileName() ); - nbMatch ++; + String fn = dir.fileName(); + buildMlsdLine( dir.isDir(), makeDateTimeStr( dtStr, dir.fileModDate(), dir.fileModTime()), + long( dir.fileSize()), fn.c_str()); + sendListLine(); return true; } #else @@ -2150,18 +2236,11 @@ bool FtpServer::doMlsd() DEBUG_PRINTLN(gfmt); if( gfmt ) { - data.print( F("Type=") ); data.print( ( file.isDir() ? F("dir") : F("file")) ); - data.print( F(";Modify=") ); data.print( makeDateTimeStr( dtStr, filelwd, filelwt ) ); - data.print( F(";Size=") ); data.print( long( fileSize( file )) ); data.print( F("; ") ); - file.printName( & data ); - data.println(); - - DEBUG_PRINT( F("Type=") ); DEBUG_PRINT( ( file.isDir() ? F("dir") : F("file")) ); - DEBUG_PRINT( F(";Modify=") ); DEBUG_PRINT( makeDateTimeStr( dtStr, filelwd, filelwt ) ); - DEBUG_PRINT( F(";Size=") ); DEBUG_PRINT( long( fileSize( file )) ); DEBUG_PRINT( F("; ") ); -// DEBUG_PRINT(file.name()); - DEBUG_PRINTLN(); - nbMatch ++; + char nameBuf[ FTP_CWD_SIZE ]; + file.getName( nameBuf, sizeof( nameBuf )); + buildMlsdLine( file.isDir(), makeDateTimeStr( dtStr, filelwd, filelwt ), + long( fileSize( file )), nameBuf ); + sendListLine(); } file.close(); return gfmt; @@ -2195,16 +2274,16 @@ void FtpServer::closeTransfer() data.stop(); + // Fires on every completed transfer, including an empty or sub-millisecond one. + if (FtpServer::_transferCallback) { + FtpServer::_transferCallback(FTP_TRANSFER_STOP, getFileName(&file).c_str(), bytesTransfered); + } + if( deltaT > 0 && bytesTransfered > 0 ) { DEBUG_PRINT( F(" Transfer completed in ") ); DEBUG_PRINT( deltaT ); DEBUG_PRINTLN( F(" ms, ") ); DEBUG_PRINT( bytesTransfered / deltaT ); DEBUG_PRINTLN( F(" kbytes/s") ); - if (FtpServer::_transferCallback) { - FtpServer::_transferCallback(FTP_TRANSFER_STOP, getFileName(&file).c_str(), bytesTransfered); - } - - client.println(F("226-File successfully transferred") ); client.print( F("226 ") ); client.print( deltaT ); client.print( F(" ms, ") ); client.print( bytesTransfered / deltaT ); client.println( F(" kbytes/s") ); @@ -2213,11 +2292,18 @@ void FtpServer::closeTransfer() client.println(F("226 File successfully transferred") ); } -void FtpServer::abortTransfer() +void FtpServer::abortTransfer(const __FlashStringHelper* reply) { - if( transferStage != FTP_Close ) + if( transferStage == FTP_Custom ) // caller-driven transfer: finishCustom owns onEnd + response { - if (FtpServer::_transferCallback) { + finishCustom( CustomTransfer::TR_ABORTED ); + } + else if( transferStage != FTP_Close ) + { + // A listing has no file and no byte count of its own: reporting one would hand the + // application the name and total of whatever transfer ran before it. + const bool sending_file = ( transferStage == FTP_Retrieve || transferStage == FTP_Store ); + if (sending_file && FtpServer::_transferCallback) { FtpServer::_transferCallback(FTP_TRANSFER_ERROR, getFileName(&file).c_str(), bytesTransfered); } @@ -2225,7 +2311,7 @@ void FtpServer::abortTransfer() #if STORAGE_TYPE != STORAGE_SPIFFS && STORAGE_TYPE != STORAGE_LITTLEFS && STORAGE_TYPE != STORAGE_SEEED_SD dir.close(); #endif - client.println(F("426 Transfer aborted") ); + client.println( reply ? reply : F("426 Transfer aborted") ); DEBUG_PRINTLN( F(" Transfer aborted!") ); transferStage = FTP_Close; diff --git a/FtpServer.h b/FtpServer.h index c916866..5cfebc8 100644 --- a/FtpServer.h +++ b/FtpServer.h @@ -502,6 +502,10 @@ #define FTP_CWD_SIZE FF_MAX_LFN+8 // max size of a directory name #define FTP_FIL_SIZE FF_MAX_LFN // max size of a file name #define FTP_CRED_SIZE 16 // max size of username and password +// One rendered listing entry: the two names at the limits above, the widest a long prints, +// the date makeDateTimeStrList() builds in its own char[25], the "Type=…;Size=…; " prefix, +// CRLF and the terminator. +#define FTP_LIST_LINE_SIZE (FTP_FIL_SIZE + FTP_CRED_SIZE + 64) #define FTP_NULLIP() IPAddress(0,0,0,0) enum ftpCmd { FTP_Stop = 0, // In this stage, stop any connection @@ -516,7 +520,8 @@ enum ftpTransfer { FTP_Close = 0, // In this stage, close data channel FTP_Store, // store file FTP_List, // list of files FTP_Nlst, // list of name of files - FTP_Mlsd }; // listing for machine processing + FTP_Mlsd, // listing for machine processing + FTP_Custom }; // caller-driven cooperative transfer (beginCustomTransfer) enum ftpDataConn { FTP_NoConn = 0,// No data connection FTP_Pasive, // Passive type @@ -545,6 +550,76 @@ enum FtpTransferOperation { FTP_UPLOAD_ERROR = 5 }; +class FtpServer; + +// A caller-driven data transfer, streamed cooperatively — one chunk per handleFTP() call — so +// the control channel stays responsive and nothing blocks. Use it for outputs with no standard +// FTP equivalent (a multi-file bundle, on-the-fly compression), started from a command hook via +// FtpResponse::beginCustomTransfer(). +struct CustomTransfer +{ + enum TransferResult { + TR_DONE, // transfer completed successfully + TR_ABORTED, // client aborted, connection lost, or a chunk reported an error + }; + + // Label reported to setTransferCallback on start/progress/end — a custom transfer has no + // `file`, so it supplies its own name (e.g. the download filename). May be nullptr. + const char * name; + + // Produce and send the next chunk into `data`, cooperatively (must NOT block). Return: + // >0 bytes written this tick — reported as progress; transfer continues + // 0 wrote nothing, not done — yield (e.g. socket full); retried next tick, no progress + // -1 done — TR_DONE + // <-1 error — TR_ABORTED + // The byte count drives the same progress callback the built-in RETR fires, so client + // progress and a host stall-watchdog keyed on setTransferCallback both work unchanged. + int (*sendChunk)( void * ctx, Client & data ); + + // Finalize — called EXACTLY once for every outcome (done, aborted, connection lost), so it + // owns cleanup (close files, free buffers). Return a custom final response line, or + // nullptr/"" for the library default (226 on success, 426 otherwise). The returned pointer + // must outlive the call (a literal, static, or a buffer owned by ctx). + const char * (*onEnd)( void * ctx, TransferResult result ); +}; + +// How a command hook reacts to the current command (Express-`res` style). A deliberately +// narrow handle: it exposes only the operations below, so a hook cannot begin()/end()/ +// handleFTP() or rebind itself. FtpServer builds one and passes it to the hook; the methods +// act on the server's internals through friendship. +class FtpResponse +{ +public: + // Send one FTP response line (e.g. "550 Read-only.") terminated by CRLF. Sending a reply + // from a command hook marks the command handled, so the built-in handler is skipped. + void reply( const char * line ); + + // Replace the command currently being processed; the library then dispatches the rewrite + // instead of the original (e.g., map "XLATEST" to "RETR " and reuse the built-in + // download). Copies are bound to the internal buffers, so cmd/param of any length are + // safe. Note: the rewrite is NOT re-filtered by the hook — apply your policy to the target + // verb, not just the incoming one. + void rewriteCommand( const char * cmd, const char * param ); + + // Whether the client has completed USER/PASS login. + bool isAuthenticated() const; + + // Force the login state, so a hook can implement its own authentication (a token, an IP + // allow-list, an extra factor) and have the rest of the session honor it. + void setAuthenticated( bool authenticated ); + + // Start a caller-driven data transfer from within a command hook: opens the data connection + // (sends "150"), then drives xfer->sendChunk once per handleFTP() call until it finishes and + // calls xfer->onEnd on any termination. `ctx` is passed to both callbacks. Returns false if + // the data connection could not be opened. Marks the command as handled. + bool beginCustomTransfer( const CustomTransfer * xfer, void * ctx ); + +private: + explicit FtpResponse( FtpServer & server ) : server_( server ) {} + FtpServer & server_; + friend class FtpServer; +}; + class FtpServer { public: @@ -569,10 +644,28 @@ class FtpServer _transferCallback = _transferCallbackParam; } + // Install a hook invoked for every command BEFORE the built-in dispatch. The hook is + // void; its intent is inferred from what it does with the FtpResponse: reply() takes the + // command over (the built-in is skipped), rewriteCommand() changes which command runs, + // and doing nothing lets the original run. The single extension point for rejecting, + // rewriting, implementing, or authorizing commands. + void setCommandHandler(void (*_commandHandlerParam)(FtpResponse& res, const char* command, const char* parameter) ) + { + _commandHandler = _commandHandlerParam; + } + private: // Use 32-bit sizes for callbacks to avoid truncation on platforms where "unsigned int" is 16-bit (AVR) void (*_callback)(FtpOperation ftpOperation, uint32_t freeSpace, uint32_t totalSpace){}; void (*_transferCallback)(FtpTransferOperation ftpOperation, const char* name, uint32_t transferredSize){}; + void (*_commandHandler)(FtpResponse& res, const char* command, const char* parameter){}; + bool _replied = false; // set by FtpResponse::reply() during a hook call; drives handled-vs-pass + friend class FtpResponse; + + const CustomTransfer * _xfer = nullptr; // active caller-driven transfer (FTP_Custom), or null + void * _customCtx = nullptr; + bool doCustom(); // push one chunk; false when the transfer ends + void finishCustom( CustomTransfer::TransferResult result ); // onEnd + final response + close, exactly once void iniVariables(); void clientConnected(); @@ -585,8 +678,23 @@ class FtpServer bool doStore(); bool doList(); bool doMlsd(); + // Sends what is still pending of listLine. True once the whole entry is away — only then + // is it counted in nbMatch, and any byte accepted pushes the idle deadline out, so a + // listing rides out a shut window exactly as a retrieve does. + bool sendListLine(); + // The one path from this server to the data socket. Returns what the socket took, and a + // non-zero take is what pushes the idle deadline out — so no transfer path can send bytes + // without the deadline noticing, or move the deadline without sending any. + size_t writeData(const uint8_t* p, size_t len); + void buildListLine(bool isNlst, bool isDirectory, const char* fn, long fz, const char* time); + void buildListLine(bool isNlst, bool isDirectory, const char* fn, long fz, time_t time); + void buildMlsdLine(bool isDirectory, const char* dtStr, long fz, const char* fn); + uint16_t finishListLine(int rendered); void closeTransfer(); - void abortTransfer(); + // Ends a transfer as FAILED: closes the file and the directory, replies exactly once — + // `reply` replaces the default "426 Transfer aborted" — and fires FTP_TRANSFER_ERROR for the + // stages that carry a file, so a listing that loses its data connection reports no transfer. + void abortTransfer(const __FlashStringHelper* reply = nullptr); bool makePath( char * fullName, char * param = nullptr ); bool makeExistsPath( char * path, char * param = nullptr ); bool openDir( FTP_DIR * pdir ); @@ -831,6 +939,14 @@ class FtpServer uint16_t iCL; // pointer to cmdLine next incoming char uint16_t nbMatch; + // One listing entry, rendered whole before any of it is sent. The directory cursor has + // already moved past the entry by then, so a line the peer only half accepts has to be + // resumed from here — re-reading it is impossible, and a half line left in the stream + // corrupts every entry after it. + char listLine[ FTP_LIST_LINE_SIZE ]; + uint16_t listLineLen = 0; // rendered length; 0 = no entry pending + uint16_t listLineSent = 0; // how much of it the socket has taken + uint32_t millisDelay, // millisEndConnection, // millisBeginTrans, // store time of beginning of a transaction