1/*
2 * Copyright 2013-2014 Haiku Inc. All rights reserved.
3 * Distributed under the terms of the MIT License.
4 *
5 * Authors:
6 * 		Fran��ois Revol, revol@free.fr
7 */
8
9
10#include <assert.h>
11#include <ctype.h>
12#include <stdlib.h>
13#include <stdio.h>
14
15#include <Directory.h>
16#include <DynamicBuffer.h>
17#include <File.h>
18#include <GopherRequest.h>
19#include <NodeInfo.h>
20#include <Path.h>
21#include <Socket.h>
22#include <StackOrHeapArray.h>
23#include <String.h>
24#include <StringList.h>
25
26using namespace BPrivate::Network;
27
28
29/*
30 * TODO: fix '+' in selectors, cf. gopher://gophernicus.org/1/doc/gopher/
31 * TODO: add proper favicon
32 * TODO: add proper dir and document icons
33 * TODO: correctly eat the extraneous .\r\n at end of text files
34 * TODO: move parsing stuff to a translator?
35 *
36 * docs:
37 * gopher://gopher.floodgap.com/1/gopher/tech
38 * gopher://gopher.floodgap.com/0/overbite/dbrowse?pluginm%201
39 *
40 * tests:
41 * gopher://sdf.org/1/sdf/historical	images
42 * gopher://gopher.r-36.net/1/	large photos
43 * gopher://sdf.org/1/sdf/classes	binaries
44 * gopher://sdf.org/1/users/	long page
45 * gopher://jgw.mdns.org/1/	search items
46 * gopher://jgw.mdns.org/1/MISC/	's' item (sound)
47 * gopher://gopher.floodgap.com/1/gopher	broken link
48 * gopher://sdf.org/1/maps/m	missing lines
49 * gopher://sdf.org/1/foo	gophernicus reports errors incorrectly
50 * gopher://gopher.floodgap.com/1/foo	correct error report
51 */
52
53/** Type of Gopher items */
54typedef enum {
55	GOPHER_TYPE_NONE	= 0,	/**< none set */
56	GOPHER_TYPE_ENDOFPAGE	= '.',	/**< a dot alone on a line */
57	/* these come from http://tools.ietf.org/html/rfc1436 */
58	GOPHER_TYPE_TEXTPLAIN	= '0',	/**< text/plain */
59	GOPHER_TYPE_DIRECTORY	= '1',	/**< gopher directory */
60	GOPHER_TYPE_CSO_SEARCH	= '2',	/**< CSO search */
61	GOPHER_TYPE_ERROR	= '3',	/**< error message */
62	GOPHER_TYPE_BINHEX	= '4',	/**< binhex encoded text */
63	GOPHER_TYPE_BINARCHIVE	= '5',	/**< binary archive file */
64	GOPHER_TYPE_UUENCODED	= '6',	/**< uuencoded text */
65	GOPHER_TYPE_QUERY	= '7',	/**< gopher search query */
66	GOPHER_TYPE_TELNET	= '8',	/**< telnet link */
67	GOPHER_TYPE_BINARY	= '9',	/**< generic binary */
68	GOPHER_TYPE_DUPSERV	= '+',	/**< duplicated server */
69	GOPHER_TYPE_GIF		= 'g',	/**< GIF image */
70	GOPHER_TYPE_IMAGE	= 'I',	/**< image (depends, usually jpeg) */
71	GOPHER_TYPE_TN3270	= 'T',	/**< tn3270 session */
72	/* not standardized but widely used,
73	 * cf. http://en.wikipedia.org/wiki/Gopher_%28protocol%29#Gopher_item_types
74	 */
75	GOPHER_TYPE_HTML	= 'h',	/**< HTML file or URL */
76	GOPHER_TYPE_INFO	= 'i',	/**< information text */
77	GOPHER_TYPE_AUDIO	= 's',	/**< audio (wav?) */
78	/* not standardized, some servers use them */
79	GOPHER_TYPE_DOC		= 'd',	/**< gophernicus uses it for PS and PDF */
80	GOPHER_TYPE_PNG		= 'p',	/**< PNG image */
81		/* cf. gopher://namcub.accelera-labs.com/1/pics */
82	GOPHER_TYPE_MIME	= 'M',	/**< multipart/mixed MIME data */
83		/* cf. http://www.pms.ifi.lmu.de/mitarbeiter/ohlbach/multimedia/IT/IBMtutorial/3376c61.html */
84	/* cf. http://nofixedpoint.motd.org/2011/02/22/an-introduction-to-the-gopher-protocol/ */
85	GOPHER_TYPE_PDF		= 'P',	/**< PDF file */
86	GOPHER_TYPE_BITMAP	= ':',	/**< Bitmap image (Gopher+) */
87	GOPHER_TYPE_MOVIE	= ';',	/**< Movie (Gopher+) */
88	GOPHER_TYPE_SOUND	= '<',	/**< Sound (Gopher+) */
89	GOPHER_TYPE_CALENDAR	= 'c',	/**< Calendar */
90	GOPHER_TYPE_EVENT	= 'e',	/**< Event */
91	GOPHER_TYPE_MBOX	= 'm',	/**< mbox file */
92} gopher_item_type;
93
94/** Types of fields in a line */
95typedef enum {
96	FIELD_NAME,
97	FIELD_SELECTOR,
98	FIELD_HOST,
99	FIELD_PORT,
100	FIELD_GPFLAG,
101	FIELD_EOL,
102	FIELD_COUNT = FIELD_EOL
103} gopher_field;
104
105/** Map of gopher types to MIME types */
106static struct {
107	gopher_item_type type;
108	const char *mime;
109} gopher_type_map[] = {
110	/* these come from http://tools.ietf.org/html/rfc1436 */
111	{ GOPHER_TYPE_TEXTPLAIN, "text/plain" },
112	{ GOPHER_TYPE_DIRECTORY, "text/html;charset=UTF-8" },
113	{ GOPHER_TYPE_QUERY, "text/html;charset=UTF-8" },
114	{ GOPHER_TYPE_GIF, "image/gif" },
115	{ GOPHER_TYPE_HTML, "text/html" },
116	/* those are not standardized */
117	{ GOPHER_TYPE_PDF, "application/pdf" },
118	{ GOPHER_TYPE_PNG, "image/png"},
119	{ GOPHER_TYPE_NONE, NULL }
120};
121
122static const char *kStyleSheet = "\n"
123"/*\n"
124" * gopher listing style\n"
125" */\n"
126"\n"
127"body#gopher {\n"
128"	/* margin: 10px;*/\n"
129"	background-color: Window;\n"
130"	color: WindowText;\n"
131"	font-size: 100%;\n"
132"	padding-bottom: 2em; }\n"
133"\n"
134"body#gopher div.uplink {\n"
135"	padding: 0;\n"
136"	margin: 0;\n"
137"	position: fixed;\n"
138"	top: 5px;\n"
139"	right: 5px; }\n"
140"\n"
141"body#gopher h1 {\n"
142"	padding: 5mm;\n"
143"	margin: 0;\n"
144"	border-bottom: 2px solid #777; }\n"
145"\n"
146"body#gopher span {\n"
147"	margin-left: 1em;\n"
148"	padding-left: 2em;\n"
149"	font-family: 'Noto Sans Mono', Courier, monospace;\n"
150"	word-wrap: break-word;\n"
151"	white-space: pre-wrap; }\n"
152"\n"
153"body#gopher span.error {\n"
154"	color: #f00; }\n"
155"\n"
156"body#gopher span.unknown {\n"
157"	color: #800; }\n"
158"\n"
159"body#gopher span.dir {\n"
160"	background-image: url('resource:icons/directory.png');\n"
161"	background-repeat: no-repeat;\n"
162"	background-position: bottom left; }\n"
163"\n"
164"body#gopher span.text {\n"
165"	background-image: url('resource:icons/content.png');\n"
166"	background-repeat: no-repeat;\n"
167"	background-position: bottom left; }\n"
168"\n"
169"body#gopher span.query {\n"
170"	background-image: url('resource:icons/search.png');\n"
171"	background-repeat: no-repeat;\n"
172"	background-position: bottom left; }\n"
173"\n"
174"body#gopher span.img img {\n"
175"	display: block;\n"
176"	margin-left:auto;\n"
177"	margin-right:auto; }\n";
178
179static const int32 kGopherBufferSize = 4096;
180
181static const bool kInlineImages = true;
182
183
184BGopherRequest::BGopherRequest(const BUrl& url, BDataIO* output,
185	BUrlProtocolListener* listener, BUrlContext* context)
186	:
187	BNetworkRequest(url, output, listener, context, "BUrlProtocol.Gopher",
188		"gopher"),
189	fItemType(GOPHER_TYPE_NONE),
190	fPosition(0),
191	fResult()
192{
193	fSocket = new(std::nothrow) BSocket();
194
195	fUrl.UrlDecode();
196	// the first part of the path is actually the document type
197
198	fPath = Url().Path();
199	if (!Url().HasPath() || fPath.Length() == 0 || fPath == "/") {
200		// default entry
201		fItemType = GOPHER_TYPE_DIRECTORY;
202		fPath = "";
203	} else if (fPath.Length() > 1 && fPath[0] == '/') {
204		fItemType = fPath[1];
205		fPath.Remove(0, 2);
206	}
207}
208
209
210BGopherRequest::~BGopherRequest()
211{
212	Stop();
213
214	delete fSocket;
215}
216
217
218status_t
219BGopherRequest::Stop()
220{
221	if (fSocket != NULL) {
222		fSocket->Disconnect();
223			// Unlock any pending connect, read or write operation.
224	}
225	return BNetworkRequest::Stop();
226}
227
228
229const BUrlResult&
230BGopherRequest::Result() const
231{
232	return fResult;
233}
234
235
236status_t
237BGopherRequest::_ProtocolLoop()
238{
239	if (fSocket == NULL)
240		return B_NO_MEMORY;
241
242	if (!_ResolveHostName(fUrl.Host(), fUrl.HasPort() ? fUrl.Port() : 70)) {
243		_EmitDebug(B_URL_PROTOCOL_DEBUG_ERROR,
244			"Unable to resolve hostname (%s), aborting.",
245				fUrl.Host().String());
246		return B_SERVER_NOT_FOUND;
247	}
248
249	_EmitDebug(B_URL_PROTOCOL_DEBUG_TEXT, "Connection to %s on port %d.",
250		fUrl.Authority().String(), fRemoteAddr.Port());
251	status_t connectError = fSocket->Connect(fRemoteAddr);
252
253	if (connectError != B_OK) {
254		_EmitDebug(B_URL_PROTOCOL_DEBUG_ERROR, "Socket connection error %s",
255			strerror(connectError));
256		return connectError;
257	}
258
259	//! ProtocolHook:ConnectionOpened
260	if (fListener != NULL)
261		fListener->ConnectionOpened(this);
262
263	_EmitDebug(B_URL_PROTOCOL_DEBUG_TEXT,
264		"Connection opened, sending request.");
265
266	_SendRequest();
267	_EmitDebug(B_URL_PROTOCOL_DEBUG_TEXT, "Request sent.");
268
269	// Receive loop
270	bool receiveEnd = false;
271	status_t readError = B_OK;
272	ssize_t bytesRead = 0;
273	//ssize_t bytesReceived = 0;
274	//ssize_t bytesTotal = 0;
275	bool dataValidated = false;
276	BStackOrHeapArray<char, 4096> chunk(kGopherBufferSize);
277
278	while (!fQuit && !receiveEnd) {
279		bytesRead = fSocket->Read(chunk, kGopherBufferSize);
280
281		if (bytesRead < 0) {
282			readError = bytesRead;
283			break;
284		} else if (bytesRead == 0)
285			receiveEnd = true;
286
287		fInputBuffer.AppendData(chunk, bytesRead);
288
289		if (!dataValidated) {
290			size_t i;
291			// on error (file doesn't exist, ...) the server sends
292			// a faked directory entry with an error message
293			if (fInputBuffer.Size() && fInputBuffer.Data()[0] == '3') {
294				int tabs = 0;
295				bool crlf = false;
296
297				// make sure the buffer only contains printable characters
298				// and has at least 3 tabs before a CRLF
299				for (i = 0; i < fInputBuffer.Size(); i++) {
300					char c = fInputBuffer.Data()[i];
301					if (c == '\t') {
302						if (!crlf)
303							tabs++;
304					} else if (c == '\r' || c == '\n') {
305						if (tabs < 3)
306							break;
307						crlf = true;
308					} else if (!isprint(fInputBuffer.Data()[i])) {
309						crlf = false;
310						break;
311					}
312				}
313				if (crlf && tabs > 2 && tabs < 5) {
314					// TODO:
315					//if enough data
316					// else continue
317					fItemType = GOPHER_TYPE_DIRECTORY;
318					readError = B_RESOURCE_NOT_FOUND;
319					// continue parsing the error text anyway
320				}
321			}
322			// special case for buggy(?) Gophernicus/1.5
323			static const char *buggy = "Error: File or directory not found!";
324			if (fInputBuffer.Size() > strlen(buggy)
325				&& !memcmp(fInputBuffer.Data(), buggy, strlen(buggy))) {
326				fItemType = GOPHER_TYPE_DIRECTORY;
327				readError = B_RESOURCE_NOT_FOUND;
328				// continue parsing the error text anyway
329				// but it won't look good
330			}
331
332			// now we probably have correct data
333			dataValidated = true;
334
335			//! ProtocolHook:ResponseStarted
336			if (fListener != NULL)
337				fListener->ResponseStarted(this);
338
339			// now we can assign MIME type if we know it
340			const char *mime = "application/octet-stream";
341			for (i = 0; gopher_type_map[i].type != GOPHER_TYPE_NONE; i++) {
342				if (gopher_type_map[i].type == fItemType) {
343					mime = gopher_type_map[i].mime;
344					break;
345				}
346			}
347			fResult.SetContentType(mime);
348
349			// we don't really have headers but well...
350			//! ProtocolHook:HeadersReceived
351			if (fListener != NULL)
352				fListener->HeadersReceived(this);
353		}
354
355		if (_NeedsParsing())
356			readError = _ParseInput(receiveEnd);
357		else if (fInputBuffer.Size()) {
358			// send input directly
359			if (fOutput != NULL) {
360				size_t written = 0;
361				readError = fOutput->WriteExactly(
362					(const char*)fInputBuffer.Data(), fInputBuffer.Size(),
363					&written);
364				if (fListener != NULL && written > 0)
365					fListener->BytesWritten(this, written);
366				if (readError != B_OK)
367					break;
368			}
369
370			fPosition += fInputBuffer.Size();
371
372			if (fListener != NULL)
373				fListener->DownloadProgress(this, fPosition, 0);
374
375			// XXX: this is plain stupid, we already copied the data
376			// and just want to drop it...
377			char *inputTempBuffer = new(std::nothrow) char[bytesRead];
378			if (inputTempBuffer == NULL) {
379				readError = B_NO_MEMORY;
380				break;
381			}
382			fInputBuffer.RemoveData(inputTempBuffer, fInputBuffer.Size());
383			delete[] inputTempBuffer;
384		}
385	}
386
387	if (fPosition > 0)
388		fResult.SetLength(fPosition);
389
390	fSocket->Disconnect();
391
392	if (readError != B_OK)
393		return readError;
394
395	return fQuit ? B_INTERRUPTED : B_OK;
396}
397
398
399void
400BGopherRequest::_SendRequest()
401{
402	BString request;
403
404	request << fPath;
405
406	if (Url().HasRequest())
407		request << '\t' << Url().Request();
408
409	request << "\r\n";
410
411	fSocket->Write(request.String(), request.Length());
412}
413
414
415bool
416BGopherRequest::_NeedsParsing()
417{
418	if (fItemType == GOPHER_TYPE_DIRECTORY
419		|| fItemType == GOPHER_TYPE_QUERY)
420		return true;
421	return false;
422}
423
424
425bool
426BGopherRequest::_NeedsLastDotStrip()
427{
428	if (fItemType == GOPHER_TYPE_DIRECTORY
429		|| fItemType == GOPHER_TYPE_QUERY
430		|| fItemType == GOPHER_TYPE_TEXTPLAIN)
431		return true;
432	return false;
433}
434
435
436status_t
437BGopherRequest::_ParseInput(bool last)
438{
439	BString line;
440
441	while (_GetLine(line) == B_OK) {
442		char type = GOPHER_TYPE_NONE;
443		BStringList fields;
444
445		line.MoveInto(&type, 0, 1);
446
447		line.Split("\t", false, fields);
448
449		if (type != GOPHER_TYPE_ENDOFPAGE
450			&& fields.CountStrings() < FIELD_GPFLAG)
451			_EmitDebug(B_URL_PROTOCOL_DEBUG_TEXT,
452				"Unterminated gopher item (type '%c')", type);
453
454		BString pageTitle;
455		BString item;
456		BString title = fields.StringAt(FIELD_NAME);
457		BString link("gopher://");
458		BString user;
459		if (fields.CountStrings() > 3) {
460			link << fields.StringAt(FIELD_HOST);
461			if (fields.StringAt(FIELD_PORT).Length())
462				link << ":" << fields.StringAt(FIELD_PORT);
463			link << "/" << type;
464			//if (fields.StringAt(FIELD_SELECTOR).ByteAt(0) != '/')
465			//	link << "/";
466			link << fields.StringAt(FIELD_SELECTOR);
467		}
468		_HTMLEscapeString(title);
469		_HTMLEscapeString(link);
470
471		switch (type) {
472			case GOPHER_TYPE_ENDOFPAGE:
473				/* end of the page */
474				break;
475			case GOPHER_TYPE_TEXTPLAIN:
476				item << "<a href=\"" << link << "\">"
477						"<span class=\"text\">" << title << "</span></a>"
478						"<br/>\n";
479				break;
480			case GOPHER_TYPE_BINARY:
481			case GOPHER_TYPE_BINHEX:
482			case GOPHER_TYPE_BINARCHIVE:
483			case GOPHER_TYPE_UUENCODED:
484				item << "<a href=\"" << link << "\">"
485						"<span class=\"binary\">" << title << "</span></a>"
486						"<br/>\n";
487				break;
488			case GOPHER_TYPE_DIRECTORY:
489				/*
490				 * directory link
491				 */
492				item << "<a href=\"" << link << "\">"
493						"<span class=\"dir\">" << title << "</span></a>"
494						"<br/>\n";
495				break;
496			case GOPHER_TYPE_ERROR:
497				item << "<span class=\"error\">" << title << "</span>"
498						"<br/>\n";
499				if (fPosition == 0 && pageTitle.Length() == 0)
500					pageTitle << "Error: " << title;
501				break;
502			case GOPHER_TYPE_QUERY:
503				/* TODO: handle search better.
504				 * For now we use an unnamed input field and accept sending ?=foo
505				 * as it seems at least Veronica-2 ignores the = but it's unclean.
506				 */
507				item << "<form method=\"get\" action=\"" << link << "\" "
508							"onsubmit=\"window.location = this.action + '?' + "
509								"this.elements['q'].value; return false;\">"
510						"<span class=\"query\">"
511						"<label>" << title << " "
512						"<input id=\"q\" name=\"\" type=\"text\" align=\"right\" />"
513						"</label>"
514						"</span></form>"
515						"<br/>\n";
516				break;
517			case GOPHER_TYPE_TELNET:
518				/* telnet: links
519				 * cf. gopher://78.80.30.202/1/ps3
520				 * -> gopher://78.80.30.202:23/8/ps3/new -> new@78.80.30.202
521				 */
522				link = "telnet://";
523				user = fields.StringAt(FIELD_SELECTOR);
524				if (user.FindLast('/') > -1) {
525					user.Remove(0, user.FindLast('/'));
526					link << user << "@";
527				}
528				link << fields.StringAt(FIELD_HOST);
529				if (fields.StringAt(FIELD_PORT) != "23")
530					link << ":" << fields.StringAt(FIELD_PORT);
531
532				item << "<a href=\"" << link << "\">"
533						"<span class=\"telnet\">" << title << "</span></a>"
534						"<br/>\n";
535				break;
536			case GOPHER_TYPE_TN3270:
537				/* tn3270: URI scheme, cf. http://tools.ietf.org/html/rfc6270 */
538				link = "tn3270://";
539				user = fields.StringAt(FIELD_SELECTOR);
540				if (user.FindLast('/') > -1) {
541					user.Remove(0, user.FindLast('/'));
542					link << user << "@";
543				}
544				link << fields.StringAt(FIELD_HOST);
545				if (fields.StringAt(FIELD_PORT) != "23")
546					link << ":" << fields.StringAt(FIELD_PORT);
547
548				item << "<a href=\"" << link << "\">"
549						"<span class=\"telnet\">" << title << "</span></a>"
550						"<br/>\n";
551				break;
552			case GOPHER_TYPE_CSO_SEARCH:
553				/* CSO search.
554				 * At least Lynx supports a cso:// URI scheme:
555				 * http://lynx.isc.org/lynx2.8.5/lynx2-8-5/lynx_help/lynx_url_support.html
556				 */
557				link = "cso://";
558				user = fields.StringAt(FIELD_SELECTOR);
559				if (user.FindLast('/') > -1) {
560					user.Remove(0, user.FindLast('/'));
561					link << user << "@";
562				}
563				link << fields.StringAt(FIELD_HOST);
564				if (fields.StringAt(FIELD_PORT) != "105")
565					link << ":" << fields.StringAt(FIELD_PORT);
566
567				item << "<a href=\"" << link << "\">"
568						"<span class=\"cso\">" << title << "</span></a>"
569						"<br/>\n";
570				break;
571			case GOPHER_TYPE_GIF:
572			case GOPHER_TYPE_IMAGE:
573			case GOPHER_TYPE_PNG:
574			case GOPHER_TYPE_BITMAP:
575				/* quite dangerous, cf. gopher://namcub.accela-labs.com/1/pics */
576				if (kInlineImages) {
577					item << "<a href=\"" << link << "\">"
578							"<span class=\"img\">" << title << " "
579							"<img src=\"" << link << "\" "
580								"alt=\"" << title << "\"/>"
581							"</span></a>"
582							"<br/>\n";
583					break;
584				}
585				/* fallback to default, link them */
586				item << "<a href=\"" << link << "\">"
587						"<span class=\"img\">" << title << "</span></a>"
588						"<br/>\n";
589				break;
590			case GOPHER_TYPE_HTML:
591				/* cf. gopher://pineapple.vg/1 */
592				if (fields.StringAt(FIELD_SELECTOR).StartsWith("URL:")) {
593					link = fields.StringAt(FIELD_SELECTOR);
594					link.Remove(0, 4);
595				}
596				/* cf. gopher://sdf.org/1/sdf/classes/ */
597
598				item << "<a href=\"" << link << "\">"
599						"<span class=\"html\">" << title << "</span></a>"
600						"<br/>\n";
601				break;
602			case GOPHER_TYPE_INFO:
603				// TITLE resource, cf.
604				// gopher://gophernicus.org/0/doc/gopher/gopher-title-resource.txt
605				if (fPosition == 0 && pageTitle.Length() == 0
606					&& fields.StringAt(FIELD_SELECTOR) == "TITLE") {
607						pageTitle = title;
608						break;
609				}
610				item << "<span class=\"info\">" << title << "</span>"
611						"<br/>\n";
612				break;
613			case GOPHER_TYPE_AUDIO:
614			case GOPHER_TYPE_SOUND:
615				item << "<a href=\"" << link << "\">"
616						"<span class=\"audio\">" << title << "</span></a>"
617						"<audio src=\"" << link << "\" "
618							//TODO:Fix crash in WebPositive with these
619							//"controls=\"controls\" "
620							//"width=\"300\" height=\"50\" "
621							"alt=\"" << title << "\"/>"
622						"<span>[player]</span></audio>"
623						"<br/>\n";
624				break;
625			case GOPHER_TYPE_PDF:
626			case GOPHER_TYPE_DOC:
627				/* generic case for known-to-work items */
628				item << "<a href=\"" << link << "\">"
629						"<span class=\"document\">" << title << "</span></a>"
630						"<br/>\n";
631				break;
632			case GOPHER_TYPE_MOVIE:
633				item << "<a href=\"" << link << "\">"
634						"<span class=\"video\">" << title << "</span></a>"
635						"<video src=\"" << link << "\" "
636							//TODO:Fix crash in WebPositive with these
637							//"controls=\"controls\" "
638							//"width=\"300\" height=\"300\" "
639							"alt=\"" << title << "\"/>"
640						"<span>[player]</span></audio>"
641						"<br/>\n";
642				break;
643			default:
644				_EmitDebug(B_URL_PROTOCOL_DEBUG_TEXT,
645					"Unknown gopher item (type 0x%02x '%c')", type, type);
646				item << "<a href=\"" << link << "\">"
647						"<span class=\"unknown\">" << title << "</span></a>"
648						"<br/>\n";
649				break;
650		}
651
652		if (fPosition == 0) {
653			if (pageTitle.Length() == 0)
654				pageTitle << "Index of " << Url();
655
656			const char *uplink = ".";
657			if (fPath.EndsWith("/"))
658				uplink = "..";
659
660			// emit header
661			BString header;
662			header <<
663				"<html>\n"
664				"<head>\n"
665				"<meta http-equiv=\"Content-Type\""
666					" content=\"text/html; charset=UTF-8\" />\n"
667				//FIXME: fix links
668				//"<link rel=\"icon\" type=\"image/png\""
669				//	" href=\"resource:icons/directory.png\">\n"
670				"<style type=\"text/css\">\n" << kStyleSheet << "</style>\n"
671				"<title>" << pageTitle << "</title>\n"
672				"</head>\n"
673				"<body id=\"gopher\">\n"
674				"<div class=\"uplink dontprint\">\n"
675				"<a href=" << uplink << ">[up]</a>\n"
676				"<a href=\"/\">[top]</a>\n"
677				"</div>\n"
678				"<h1>" << pageTitle << "</h1>\n";
679
680			if (fOutput != NULL) {
681				size_t written = 0;
682				status_t error = fOutput->WriteExactly(header.String(),
683					header.Length(), &written);
684				if (fListener != NULL && written > 0)
685					fListener->BytesWritten(this, written);
686				if (error != B_OK)
687					return error;
688			}
689
690			fPosition += header.Length();
691
692			if (fListener != NULL)
693				fListener->DownloadProgress(this, fPosition, 0);
694		}
695
696		if (item.Length()) {
697			if (fOutput != NULL) {
698				size_t written = 0;
699				status_t error = fOutput->WriteExactly(item.String(),
700					item.Length(), &written);
701				if (fListener != NULL && written > 0)
702					fListener->BytesWritten(this, written);
703				if (error != B_OK)
704					return error;
705			}
706
707			fPosition += item.Length();
708
709			if (fListener != NULL)
710				fListener->DownloadProgress(this, fPosition, 0);
711		}
712	}
713
714	if (last) {
715		// emit footer
716		BString footer =
717			"</div>\n"
718			"</body>\n"
719			"</html>\n";
720
721		if (fListener != NULL) {
722			size_t written = 0;
723			status_t error = fOutput->WriteExactly(footer.String(),
724				footer.Length(), &written);
725			if (fListener != NULL && written > 0)
726				fListener->BytesWritten(this, written);
727			if (error != B_OK)
728				return error;
729		}
730
731		fPosition += footer.Length();
732
733		if (fListener != NULL)
734			fListener->DownloadProgress(this, fPosition, 0);
735	}
736
737	return B_OK;
738}
739
740
741BString&
742BGopherRequest::_HTMLEscapeString(BString &str)
743{
744	str.ReplaceAll("&", "&amp;");
745	str.ReplaceAll("<", "&lt;");
746	str.ReplaceAll(">", "&gt;");
747	return str;
748}
749