root/src/kits/network/libnetservices/GopherRequest.cpp
/*
 * Copyright 2013-2014 Haiku Inc. All rights reserved.
 * Distributed under the terms of the MIT License.
 *
 * Authors:
 *              François Revol, revol@free.fr
 */


#include <assert.h>
#include <ctype.h>
#include <stdlib.h>
#include <stdio.h>

#include <Directory.h>
#include <DynamicBuffer.h>
#include <File.h>
#include <GopherRequest.h>
#include <NodeInfo.h>
#include <Path.h>
#include <Socket.h>
#include <StackOrHeapArray.h>
#include <String.h>
#include <StringList.h>

using namespace BPrivate::Network;


/*
 * TODO: fix '+' in selectors, cf. gopher://gophernicus.org/1/doc/gopher/
 * TODO: add proper favicon
 * TODO: add proper dir and document icons
 * TODO: correctly eat the extraneous .\r\n at end of text files
 * TODO: move parsing stuff to a translator?
 *
 * docs:
 * gopher://gopher.floodgap.com/1/gopher/tech
 * gopher://gopher.floodgap.com/0/overbite/dbrowse?pluginm%201
 *
 * tests:
 * gopher://sdf.org/1/sdf/historical    images
 * gopher://gopher.r-36.net/1/  large photos
 * gopher://sdf.org/1/sdf/classes       binaries
 * gopher://sdf.org/1/users/    long page
 * gopher://jgw.mdns.org/1/     search items
 * gopher://jgw.mdns.org/1/MISC/        's' item (sound)
 * gopher://gopher.floodgap.com/1/gopher        broken link
 * gopher://sdf.org/1/maps/m    missing lines
 * gopher://sdf.org/1/foo       gophernicus reports errors incorrectly
 * gopher://gopher.floodgap.com/1/foo   correct error report
 */

/** Type of Gopher items */
typedef enum {
        GOPHER_TYPE_NONE        = 0,    /**< none set */
        GOPHER_TYPE_ENDOFPAGE   = '.',  /**< a dot alone on a line */
        /* these come from http://tools.ietf.org/html/rfc1436 */
        GOPHER_TYPE_TEXTPLAIN   = '0',  /**< text/plain */
        GOPHER_TYPE_DIRECTORY   = '1',  /**< gopher directory */
        GOPHER_TYPE_CSO_SEARCH  = '2',  /**< CSO search */
        GOPHER_TYPE_ERROR       = '3',  /**< error message */
        GOPHER_TYPE_BINHEX      = '4',  /**< binhex encoded text */
        GOPHER_TYPE_BINARCHIVE  = '5',  /**< binary archive file */
        GOPHER_TYPE_UUENCODED   = '6',  /**< uuencoded text */
        GOPHER_TYPE_QUERY       = '7',  /**< gopher search query */
        GOPHER_TYPE_TELNET      = '8',  /**< telnet link */
        GOPHER_TYPE_BINARY      = '9',  /**< generic binary */
        GOPHER_TYPE_DUPSERV     = '+',  /**< duplicated server */
        GOPHER_TYPE_GIF         = 'g',  /**< GIF image */
        GOPHER_TYPE_IMAGE       = 'I',  /**< image (depends, usually jpeg) */
        GOPHER_TYPE_TN3270      = 'T',  /**< tn3270 session */
        /* not standardized but widely used,
         * cf. http://en.wikipedia.org/wiki/Gopher_%28protocol%29#Gopher_item_types
         */
        GOPHER_TYPE_HTML        = 'h',  /**< HTML file or URL */
        GOPHER_TYPE_INFO        = 'i',  /**< information text */
        GOPHER_TYPE_AUDIO       = 's',  /**< audio (wav?) */
        /* not standardized, some servers use them */
        GOPHER_TYPE_DOC         = 'd',  /**< gophernicus uses it for PS and PDF */
        GOPHER_TYPE_PNG         = 'p',  /**< PNG image */
                /* cf. gopher://namcub.accelera-labs.com/1/pics */
        GOPHER_TYPE_MIME        = 'M',  /**< multipart/mixed MIME data */
                /* cf. http://www.pms.ifi.lmu.de/mitarbeiter/ohlbach/multimedia/IT/IBMtutorial/3376c61.html */
        /* cf. http://nofixedpoint.motd.org/2011/02/22/an-introduction-to-the-gopher-protocol/ */
        GOPHER_TYPE_PDF         = 'P',  /**< PDF file */
        GOPHER_TYPE_BITMAP      = ':',  /**< Bitmap image (Gopher+) */
        GOPHER_TYPE_MOVIE       = ';',  /**< Movie (Gopher+) */
        GOPHER_TYPE_SOUND       = '<',  /**< Sound (Gopher+) */
        GOPHER_TYPE_CALENDAR    = 'c',  /**< Calendar */
        GOPHER_TYPE_EVENT       = 'e',  /**< Event */
        GOPHER_TYPE_MBOX        = 'm',  /**< mbox file */
} gopher_item_type;

/** Types of fields in a line */
typedef enum {
        FIELD_NAME,
        FIELD_SELECTOR,
        FIELD_HOST,
        FIELD_PORT,
        FIELD_GPFLAG,
        FIELD_EOL,
        FIELD_COUNT = FIELD_EOL
} gopher_field;

/** Map of gopher types to MIME types */
static struct {
        gopher_item_type type;
        const char *mime;
} gopher_type_map[] = {
        /* these come from http://tools.ietf.org/html/rfc1436 */
        { GOPHER_TYPE_TEXTPLAIN, "text/plain" },
        { GOPHER_TYPE_DIRECTORY, "text/html;charset=UTF-8" },
        { GOPHER_TYPE_QUERY, "text/html;charset=UTF-8" },
        { GOPHER_TYPE_GIF, "image/gif" },
        { GOPHER_TYPE_HTML, "text/html" },
        /* those are not standardized */
        { GOPHER_TYPE_PDF, "application/pdf" },
        { GOPHER_TYPE_PNG, "image/png"},
        { GOPHER_TYPE_NONE, NULL }
};

static const char *kStyleSheet = "\n"
"/*\n"
" * gopher listing style\n"
" */\n"
"\n"
"body#gopher {\n"
"       /* margin: 10px;*/\n"
"       background-color: Window;\n"
"       color: WindowText;\n"
"       font-size: 100%;\n"
"       padding-bottom: 2em; }\n"
"\n"
"body#gopher div.uplink {\n"
"       padding: 0;\n"
"       margin: 0;\n"
"       position: fixed;\n"
"       top: 5px;\n"
"       right: 5px; }\n"
"\n"
"body#gopher h1 {\n"
"       padding: 5mm;\n"
"       margin: 0;\n"
"       border-bottom: 2px solid #777; }\n"
"\n"
"body#gopher span {\n"
"       margin-left: 1em;\n"
"       padding-left: 2em;\n"
"       font-family: 'Noto Sans Mono', Courier, monospace;\n"
"       word-wrap: break-word;\n"
"       white-space: pre-wrap; }\n"
"\n"
"body#gopher span.error {\n"
"       color: #f00; }\n"
"\n"
"body#gopher span.unknown {\n"
"       color: #800; }\n"
"\n"
"body#gopher span.dir {\n"
"       background-image: url('resource:icons/directory.png');\n"
"       background-repeat: no-repeat;\n"
"       background-position: bottom left; }\n"
"\n"
"body#gopher span.text {\n"
"       background-image: url('resource:icons/content.png');\n"
"       background-repeat: no-repeat;\n"
"       background-position: bottom left; }\n"
"\n"
"body#gopher span.query {\n"
"       background-image: url('resource:icons/search.png');\n"
"       background-repeat: no-repeat;\n"
"       background-position: bottom left; }\n"
"\n"
"body#gopher span.img img {\n"
"       display: block;\n"
"       margin-left:auto;\n"
"       margin-right:auto; }\n";

static const int32 kGopherBufferSize = 4096;

static const bool kInlineImages = true;


BGopherRequest::BGopherRequest(const BUrl& url, BDataIO* output,
        BUrlProtocolListener* listener, BUrlContext* context)
        :
        BNetworkRequest(url, output, listener, context, "BUrlProtocol.Gopher",
                "gopher"),
        fItemType(GOPHER_TYPE_NONE),
        fPosition(0),
        fResult()
{
        fSocket = new(std::nothrow) BSocket();

        // the first part of the path is actually the document type

        fPath = Url().Path();
        if (!Url().HasPath() || fPath.Length() == 0 || fPath == "/") {
                // default entry
                fItemType = GOPHER_TYPE_DIRECTORY;
                fPath = "";
        } else if (fPath.Length() > 1 && fPath[0] == '/') {
                fItemType = fPath[1];
                fPath.Remove(0, 2);
        }
}


BGopherRequest::~BGopherRequest()
{
        Stop();

        delete fSocket;
}


status_t
BGopherRequest::Stop()
{
        if (fSocket != NULL) {
                fSocket->Disconnect();
                        // Unlock any pending connect, read or write operation.
        }
        return BNetworkRequest::Stop();
}


const BUrlResult&
BGopherRequest::Result() const
{
        return fResult;
}


status_t
BGopherRequest::_ProtocolLoop()
{
        if (fSocket == NULL)
                return B_NO_MEMORY;

        if (!_ResolveHostName(fUrl.Host(), fUrl.HasPort() ? fUrl.Port() : 70)) {
                _EmitDebug(B_URL_PROTOCOL_DEBUG_ERROR,
                        "Unable to resolve hostname (%s), aborting.",
                                fUrl.Host().String());
                return B_SERVER_NOT_FOUND;
        }

        _EmitDebug(B_URL_PROTOCOL_DEBUG_TEXT, "Connection to %s on port %d.",
                fUrl.Authority().String(), fRemoteAddr.Port());
        status_t connectError = fSocket->Connect(fRemoteAddr);

        if (connectError != B_OK) {
                _EmitDebug(B_URL_PROTOCOL_DEBUG_ERROR, "Socket connection error %s",
                        strerror(connectError));
                return connectError;
        }

        //! ProtocolHook:ConnectionOpened
        if (fListener != NULL)
                fListener->ConnectionOpened(this);

        _EmitDebug(B_URL_PROTOCOL_DEBUG_TEXT,
                "Connection opened, sending request.");

        _SendRequest();
        _EmitDebug(B_URL_PROTOCOL_DEBUG_TEXT, "Request sent.");

        // Receive loop
        bool receiveEnd = false;
        status_t readError = B_OK;
        ssize_t bytesRead = 0;
        //ssize_t bytesReceived = 0;
        //ssize_t bytesTotal = 0;
        bool dataValidated = false;
        BStackOrHeapArray<char, 4096> chunk(kGopherBufferSize);

        while (!fQuit && !receiveEnd) {
                bytesRead = fSocket->Read(chunk, kGopherBufferSize);

                if (bytesRead < 0) {
                        readError = bytesRead;
                        break;
                } else if (bytesRead == 0)
                        receiveEnd = true;

                fInputBuffer.AppendData(chunk, bytesRead);

                if (!dataValidated) {
                        size_t i;
                        // on error (file doesn't exist, ...) the server sends
                        // a faked directory entry with an error message
                        if (fInputBuffer.Size() && fInputBuffer.Data()[0] == '3') {
                                int tabs = 0;
                                bool crlf = false;

                                // make sure the buffer only contains printable characters
                                // and has at least 3 tabs before a CRLF
                                for (i = 0; i < fInputBuffer.Size(); i++) {
                                        char c = fInputBuffer.Data()[i];
                                        if (c == '\t') {
                                                if (!crlf)
                                                        tabs++;
                                        } else if (c == '\r' || c == '\n') {
                                                if (tabs < 3)
                                                        break;
                                                crlf = true;
                                        } else if (!isprint(fInputBuffer.Data()[i])) {
                                                crlf = false;
                                                break;
                                        }
                                }
                                if (crlf && tabs > 2 && tabs < 5) {
                                        // TODO:
                                        //if enough data
                                        // else continue
                                        fItemType = GOPHER_TYPE_DIRECTORY;
                                        readError = B_RESOURCE_NOT_FOUND;
                                        // continue parsing the error text anyway
                                }
                        }
                        // special case for buggy(?) Gophernicus/1.5
                        static const char *buggy = "Error: File or directory not found!";
                        if (fInputBuffer.Size() > strlen(buggy)
                                && !memcmp(fInputBuffer.Data(), buggy, strlen(buggy))) {
                                fItemType = GOPHER_TYPE_DIRECTORY;
                                readError = B_RESOURCE_NOT_FOUND;
                                // continue parsing the error text anyway
                                // but it won't look good
                        }

                        // now we probably have correct data
                        dataValidated = true;

                        //! ProtocolHook:ResponseStarted
                        if (fListener != NULL)
                                fListener->ResponseStarted(this);

                        // now we can assign MIME type if we know it
                        const char *mime = "application/octet-stream";
                        for (i = 0; gopher_type_map[i].type != GOPHER_TYPE_NONE; i++) {
                                if (gopher_type_map[i].type == fItemType) {
                                        mime = gopher_type_map[i].mime;
                                        break;
                                }
                        }
                        fResult.SetContentType(mime);

                        // we don't really have headers but well...
                        //! ProtocolHook:HeadersReceived
                        if (fListener != NULL)
                                fListener->HeadersReceived(this);
                }

                if (_NeedsParsing())
                        readError = _ParseInput(receiveEnd);
                else if (fInputBuffer.Size()) {
                        // send input directly
                        if (fOutput != NULL) {
                                size_t written = 0;
                                readError = fOutput->WriteExactly(
                                        (const char*)fInputBuffer.Data(), fInputBuffer.Size(),
                                        &written);
                                if (fListener != NULL && written > 0)
                                        fListener->BytesWritten(this, written);
                                if (readError != B_OK)
                                        break;
                        }

                        fPosition += fInputBuffer.Size();

                        if (fListener != NULL)
                                fListener->DownloadProgress(this, fPosition, 0);

                        // XXX: this is plain stupid, we already copied the data
                        // and just want to drop it...
                        char *inputTempBuffer = new(std::nothrow) char[bytesRead];
                        if (inputTempBuffer == NULL) {
                                readError = B_NO_MEMORY;
                                break;
                        }
                        fInputBuffer.RemoveData(inputTempBuffer, fInputBuffer.Size());
                        delete[] inputTempBuffer;
                }
        }

        if (fPosition > 0)
                fResult.SetLength(fPosition);

        fSocket->Disconnect();

        if (readError != B_OK)
                return readError;

        return fQuit ? B_INTERRUPTED : B_OK;
}


void
BGopherRequest::_SendRequest()
{
        BString request;

        request << fPath;

        if (Url().HasRequest())
                request << '\t' << Url().Request();

        request << "\r\n";

        fSocket->Write(request.String(), request.Length());
}


bool
BGopherRequest::_NeedsParsing()
{
        if (fItemType == GOPHER_TYPE_DIRECTORY
                || fItemType == GOPHER_TYPE_QUERY)
                return true;
        return false;
}


bool
BGopherRequest::_NeedsLastDotStrip()
{
        if (fItemType == GOPHER_TYPE_DIRECTORY
                || fItemType == GOPHER_TYPE_QUERY
                || fItemType == GOPHER_TYPE_TEXTPLAIN)
                return true;
        return false;
}


status_t
BGopherRequest::_ParseInput(bool last)
{
        BString line;

        while (_GetLine(line) == B_OK) {
                char type = GOPHER_TYPE_NONE;
                BStringList fields;

                line.MoveInto(&type, 0, 1);

                line.Split("\t", false, fields);

                if (type != GOPHER_TYPE_ENDOFPAGE
                        && fields.CountStrings() < FIELD_GPFLAG)
                        _EmitDebug(B_URL_PROTOCOL_DEBUG_TEXT,
                                "Unterminated gopher item (type '%c')", type);

                BString pageTitle;
                BString item;
                BString title = fields.StringAt(FIELD_NAME);
                BString link("gopher://");
                BString user;
                if (fields.CountStrings() > 3) {
                        link << fields.StringAt(FIELD_HOST);
                        if (fields.StringAt(FIELD_PORT).Length())
                                link << ":" << fields.StringAt(FIELD_PORT);
                        link << "/" << type;
                        //if (fields.StringAt(FIELD_SELECTOR).ByteAt(0) != '/')
                        //      link << "/";
                        link << fields.StringAt(FIELD_SELECTOR);
                }
                _HTMLEscapeString(title);
                _HTMLEscapeString(link);

                switch (type) {
                        case GOPHER_TYPE_ENDOFPAGE:
                                /* end of the page */
                                break;
                        case GOPHER_TYPE_TEXTPLAIN:
                                item << "<a href=\"" << link << "\">"
                                                "<span class=\"text\">" << title << "</span></a>"
                                                "<br/>\n";
                                break;
                        case GOPHER_TYPE_BINARY:
                        case GOPHER_TYPE_BINHEX:
                        case GOPHER_TYPE_BINARCHIVE:
                        case GOPHER_TYPE_UUENCODED:
                                item << "<a href=\"" << link << "\">"
                                                "<span class=\"binary\">" << title << "</span></a>"
                                                "<br/>\n";
                                break;
                        case GOPHER_TYPE_DIRECTORY:
                                /*
                                 * directory link
                                 */
                                item << "<a href=\"" << link << "\">"
                                                "<span class=\"dir\">" << title << "</span></a>"
                                                "<br/>\n";
                                break;
                        case GOPHER_TYPE_ERROR:
                                item << "<span class=\"error\">" << title << "</span>"
                                                "<br/>\n";
                                if (fPosition == 0 && pageTitle.Length() == 0)
                                        pageTitle << "Error: " << title;
                                break;
                        case GOPHER_TYPE_QUERY:
                                /* TODO: handle search better.
                                 * For now we use an unnamed input field and accept sending ?=foo
                                 * as it seems at least Veronica-2 ignores the = but it's unclean.
                                 */
                                item << "<form method=\"get\" action=\"" << link << "\" "
                                                        "onsubmit=\"window.location = this.action + '?' + "
                                                                "this.elements['q'].value; return false;\">"
                                                "<span class=\"query\">"
                                                "<label>" << title << " "
                                                "<input id=\"q\" name=\"\" type=\"text\" align=\"right\" />"
                                                "</label>"
                                                "</span></form>"
                                                "<br/>\n";
                                break;
                        case GOPHER_TYPE_TELNET:
                                /* telnet: links
                                 * cf. gopher://78.80.30.202/1/ps3
                                 * -> gopher://78.80.30.202:23/8/ps3/new -> new@78.80.30.202
                                 */
                                link = "telnet://";
                                user = fields.StringAt(FIELD_SELECTOR);
                                if (user.FindLast('/') > -1) {
                                        user.Remove(0, user.FindLast('/'));
                                        link << user << "@";
                                }
                                link << fields.StringAt(FIELD_HOST);
                                if (fields.StringAt(FIELD_PORT) != "23")
                                        link << ":" << fields.StringAt(FIELD_PORT);

                                item << "<a href=\"" << link << "\">"
                                                "<span class=\"telnet\">" << title << "</span></a>"
                                                "<br/>\n";
                                break;
                        case GOPHER_TYPE_TN3270:
                                /* tn3270: URI scheme, cf. http://tools.ietf.org/html/rfc6270 */
                                link = "tn3270://";
                                user = fields.StringAt(FIELD_SELECTOR);
                                if (user.FindLast('/') > -1) {
                                        user.Remove(0, user.FindLast('/'));
                                        link << user << "@";
                                }
                                link << fields.StringAt(FIELD_HOST);
                                if (fields.StringAt(FIELD_PORT) != "23")
                                        link << ":" << fields.StringAt(FIELD_PORT);

                                item << "<a href=\"" << link << "\">"
                                                "<span class=\"telnet\">" << title << "</span></a>"
                                                "<br/>\n";
                                break;
                        case GOPHER_TYPE_CSO_SEARCH:
                                /* CSO search.
                                 * At least Lynx supports a cso:// URI scheme:
                                 * http://lynx.isc.org/lynx2.8.5/lynx2-8-5/lynx_help/lynx_url_support.html
                                 */
                                link = "cso://";
                                user = fields.StringAt(FIELD_SELECTOR);
                                if (user.FindLast('/') > -1) {
                                        user.Remove(0, user.FindLast('/'));
                                        link << user << "@";
                                }
                                link << fields.StringAt(FIELD_HOST);
                                if (fields.StringAt(FIELD_PORT) != "105")
                                        link << ":" << fields.StringAt(FIELD_PORT);

                                item << "<a href=\"" << link << "\">"
                                                "<span class=\"cso\">" << title << "</span></a>"
                                                "<br/>\n";
                                break;
                        case GOPHER_TYPE_GIF:
                        case GOPHER_TYPE_IMAGE:
                        case GOPHER_TYPE_PNG:
                        case GOPHER_TYPE_BITMAP:
                                /* quite dangerous, cf. gopher://namcub.accela-labs.com/1/pics */
                                if (kInlineImages) {
                                        item << "<a href=\"" << link << "\">"
                                                        "<span class=\"img\">" << title << " "
                                                        "<img src=\"" << link << "\" "
                                                                "alt=\"" << title << "\"/>"
                                                        "</span></a>"
                                                        "<br/>\n";
                                        break;
                                }
                                /* fallback to default, link them */
                                item << "<a href=\"" << link << "\">"
                                                "<span class=\"img\">" << title << "</span></a>"
                                                "<br/>\n";
                                break;
                        case GOPHER_TYPE_HTML:
                                /* cf. gopher://pineapple.vg/1 */
                                if (fields.StringAt(FIELD_SELECTOR).StartsWith("URL:")) {
                                        link = fields.StringAt(FIELD_SELECTOR);
                                        link.Remove(0, 4);
                                }
                                /* cf. gopher://sdf.org/1/sdf/classes/ */

                                item << "<a href=\"" << link << "\">"
                                                "<span class=\"html\">" << title << "</span></a>"
                                                "<br/>\n";
                                break;
                        case GOPHER_TYPE_INFO:
                                // TITLE resource, cf.
                                // gopher://gophernicus.org/0/doc/gopher/gopher-title-resource.txt
                                if (fPosition == 0 && pageTitle.Length() == 0
                                        && fields.StringAt(FIELD_SELECTOR) == "TITLE") {
                                                pageTitle = title;
                                                break;
                                }
                                item << "<span class=\"info\">" << title << "</span>"
                                                "<br/>\n";
                                break;
                        case GOPHER_TYPE_AUDIO:
                        case GOPHER_TYPE_SOUND:
                                item << "<a href=\"" << link << "\">"
                                                "<span class=\"audio\">" << title << "</span></a>"
                                                "<audio src=\"" << link << "\" "
                                                        //TODO:Fix crash in WebPositive with these
                                                        //"controls=\"controls\" "
                                                        //"width=\"300\" height=\"50\" "
                                                        "alt=\"" << title << "\"/>"
                                                "<span>[player]</span></audio>"
                                                "<br/>\n";
                                break;
                        case GOPHER_TYPE_PDF:
                        case GOPHER_TYPE_DOC:
                                /* generic case for known-to-work items */
                                item << "<a href=\"" << link << "\">"
                                                "<span class=\"document\">" << title << "</span></a>"
                                                "<br/>\n";
                                break;
                        case GOPHER_TYPE_MOVIE:
                                item << "<a href=\"" << link << "\">"
                                                "<span class=\"video\">" << title << "</span></a>"
                                                "<video src=\"" << link << "\" "
                                                        //TODO:Fix crash in WebPositive with these
                                                        //"controls=\"controls\" "
                                                        //"width=\"300\" height=\"300\" "
                                                        "alt=\"" << title << "\"/>"
                                                "<span>[player]</span></audio>"
                                                "<br/>\n";
                                break;
                        default:
                                _EmitDebug(B_URL_PROTOCOL_DEBUG_TEXT,
                                        "Unknown gopher item (type 0x%02x '%c')", type, type);
                                item << "<a href=\"" << link << "\">"
                                                "<span class=\"unknown\">" << title << "</span></a>"
                                                "<br/>\n";
                                break;
                }

                if (fPosition == 0) {
                        if (pageTitle.Length() == 0)
                                pageTitle << "Index of " << Url();

                        const char *uplink = ".";
                        if (fPath.EndsWith("/"))
                                uplink = "..";

                        // emit header
                        BString header;
                        header <<
                                "<html>\n"
                                "<head>\n"
                                "<meta http-equiv=\"Content-Type\""
                                        " content=\"text/html; charset=UTF-8\" />\n"
                                //FIXME: fix links
                                //"<link rel=\"icon\" type=\"image/png\""
                                //      " href=\"resource:icons/directory.png\">\n"
                                "<style type=\"text/css\">\n" << kStyleSheet << "</style>\n"
                                "<title>" << pageTitle << "</title>\n"
                                "</head>\n"
                                "<body id=\"gopher\">\n"
                                "<div class=\"uplink dontprint\">\n"
                                "<a href=" << uplink << ">[up]</a>\n"
                                "<a href=\"/\">[top]</a>\n"
                                "</div>\n"
                                "<h1>" << pageTitle << "</h1>\n";

                        if (fOutput != NULL) {
                                size_t written = 0;
                                status_t error = fOutput->WriteExactly(header.String(),
                                        header.Length(), &written);
                                if (fListener != NULL && written > 0)
                                        fListener->BytesWritten(this, written);
                                if (error != B_OK)
                                        return error;
                        }

                        fPosition += header.Length();

                        if (fListener != NULL)
                                fListener->DownloadProgress(this, fPosition, 0);
                }

                if (item.Length()) {
                        if (fOutput != NULL) {
                                size_t written = 0;
                                status_t error = fOutput->WriteExactly(item.String(),
                                        item.Length(), &written);
                                if (fListener != NULL && written > 0)
                                        fListener->BytesWritten(this, written);
                                if (error != B_OK)
                                        return error;
                        }

                        fPosition += item.Length();

                        if (fListener != NULL)
                                fListener->DownloadProgress(this, fPosition, 0);
                }
        }

        if (last) {
                // emit footer
                BString footer =
                        "</div>\n"
                        "</body>\n"
                        "</html>\n";

                if (fListener != NULL) {
                        size_t written = 0;
                        status_t error = fOutput->WriteExactly(footer.String(),
                                footer.Length(), &written);
                        if (fListener != NULL && written > 0)
                                fListener->BytesWritten(this, written);
                        if (error != B_OK)
                                return error;
                }

                fPosition += footer.Length();

                if (fListener != NULL)
                        fListener->DownloadProgress(this, fPosition, 0);
        }

        return B_OK;
}


BString&
BGopherRequest::_HTMLEscapeString(BString &str)
{
        str.ReplaceAll("&", "&amp;");
        str.ReplaceAll("<", "&lt;");
        str.ReplaceAll(">", "&gt;");
        return str;
}