Annotation of libwww/Library/src/HTGopher.c, revision 2.7
1.1 timbl 1: /* GOPHER ACCESS HTGopher.c
2: ** =============
3: **
4: ** History:
5: ** 26 Sep 90 Adapted from other accesses (News, HTTP) TBL
6: ** 29 Nov 91 Downgraded to C, for portable implementation.
7: */
8:
1.2 timbl 9: /* Implements:
10: */
11: #include "HTGopher.h"
12:
1.3 timbl 13: #define CR FROMASCII('\015') /* Carriage return */
14: #define LF FROMASCII('\012') /* ASCII line feed
15: (sometimes \n is CR on Mac) */
16:
1.1 timbl 17: #define GOPHER_PORT 70 /* See protocol spec */
18: #define BIG 1024 /* Bug */
19: #define LINE_LENGTH 256 /* Bug */
20:
21: /* Gopher entity types:
22: */
23: #define GOPHER_TEXT '0'
24: #define GOPHER_MENU '1'
25: #define GOPHER_CSO '2'
26: #define GOPHER_ERROR '3'
27: #define GOPHER_MACBINHEX '4'
28: #define GOPHER_PCBINHEX '5'
29: #define GOPHER_UUENCODED '6'
30: #define GOPHER_INDEX '7'
31: #define GOPHER_TELNET '8'
2.7 ! secret 32: #define GOPHER_BINARY '9'
1.3 timbl 33: #define GOPHER_GIF 'g'
2.7 ! secret 34: #define GOPHER_HTML 'h' /* HTML */
! 35: #define GOPHER_SOUND 's'
! 36: #define GOPHER_WWW 'w' /* W3 address */
1.3 timbl 37: #define GOPHER_IMAGE 'I'
2.7 ! secret 38: #define GOPHER_TN3270 'T'
1.1 timbl 39: #define GOPHER_DUPLICATE '+'
40:
41: #include <ctype.h>
42: #include "HTUtils.h" /* Coding convention macros */
43: #include "tcp.h"
44:
45:
46: #include "HTParse.h"
47: #include "HTFormat.h"
48: #include "HTTCP.h"
49:
1.2 timbl 50: /* Hypertext object building machinery
51: */
52: #include "HTML.h"
53:
54: #define PUTC(c) (*targetClass.put_character)(target, c)
55: #define PUTS(s) (*targetClass.put_string)(target, s)
56: #define START(e) (*targetClass.start_element)(target, e, 0, 0)
57: #define END(e) (*targetClass.end_element)(target, e)
58: #define END_TARGET (*targetClass.end_document)(target)
59: #define FREE_TARGET (*targetClass.free)(target)
60: struct _HTStructured {
61: CONST HTStructuredClass * isa;
62: /* ... */
63: };
64:
65: PRIVATE HTStructured *target; /* the new hypertext */
66: PRIVATE HTStructuredClass targetClass; /* Its action routines */
67:
68:
1.1 timbl 69: #ifdef NeXTStep
70: #include <appkit/defaults.h>
71: #define GOPHER_PROGRESS(foo)
72: #else
73: #define GOPHER_PROGRESS(foo) fprintf(stderr, "%s\n", (foo))
74: #endif
75:
76: #define NEXT_CHAR HTGetChararcter()
77:
78:
79:
80: /* Module-wide variables
81: */
82: PRIVATE int s; /* Socket for GopherHost */
83:
84:
1.2 timbl 85:
1.1 timbl 86: /* Matrix of allowed characters in filenames
87: ** -----------------------------------------
88: */
89:
90: PRIVATE BOOL acceptable[256];
91: PRIVATE BOOL acceptable_inited = NO;
92:
93: PRIVATE void init_acceptable NOARGS
94: {
95: unsigned int i;
96: char * good =
97: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789./-_$";
98: for(i=0; i<256; i++) acceptable[i] = NO;
99: for(;*good; good++) acceptable[(unsigned int)*good] = YES;
100: acceptable_inited = YES;
101: }
102:
103: PRIVATE CONST char hex[17] = "0123456789abcdef";
104:
105: /* Decdoe one hex character
106: */
107:
108: PRIVATE char from_hex ARGS1(char, c)
109: {
110: return (c>='0')&&(c<='9') ? c-'0'
111: : (c>='A')&&(c<='F') ? c-'A'+10
112: : (c>='a')&&(c<='f') ? c-'a'+10
113: : 0;
114: }
115:
116:
117:
118: /* Paste in an Anchor
119: ** ------------------
120: **
121: ** The title of the destination is set, as there is no way
122: ** of knowing what the title is when we arrive.
123: **
124: ** On entry,
125: ** HT is in append mode.
126: ** text points to the text to be put into the file, 0 terminated.
127: ** addr points to the hypertext refernce address 0 terminated.
128: */
129: PRIVATE void write_anchor ARGS2(CONST char *,text, CONST char *,addr)
130: {
1.2 timbl 131:
132:
133:
134: BOOL present[HTML_A_ATTRIBUTES];
135: CONST char * value[HTML_A_ATTRIBUTES];
1.1 timbl 136:
1.2 timbl 137: int i;
138:
139: for (i=0; i<HTML_A_ATTRIBUTES; i++) present[i]=0;
140: present[HTML_A_HREF] = YES;
141: value[HTML_A_HREF] = addr;
142: present[HTML_A_TITLE] = YES;
143: value[HTML_A_TITLE] = text;
144:
145: (*targetClass.start_element)(target, HTML_A, present, value);
1.1 timbl 146:
1.2 timbl 147: PUTS(text);
148: END(HTML_A);
1.1 timbl 149: }
150:
151:
152: /* Parse a Gopher Menu document
153: ** ============================
154: **
155: */
156:
157: PRIVATE void parse_menu ARGS2 (
1.2 timbl 158: CONST char *, arg,
159: HTParentAnchor *, anAnchor)
1.1 timbl 160: {
161: char gtype;
162: char ch;
163: char line[BIG];
164: char address[BIG];
165: char *name, *selector; /* Gopher menu fields */
166: char *host;
167: char *port;
168: char *p = line;
1.2 timbl 169: CONST char *title;
1.1 timbl 170:
171: #define TAB '\t'
172: #define HEX_ESCAPE '%'
173:
174:
1.2 timbl 175: title = HTAnchor_title(anAnchor);
176: if (title) {
177: START(HTML_H1);
178: PUTS(title);
179: END(HTML_H1);
180: } else
181: PUTS("Select one of:\n\n");
1.1 timbl 182:
1.2 timbl 183: START(HTML_MENU);
1.1 timbl 184: while ((ch=NEXT_CHAR) != (char)EOF) {
1.3 timbl 185: if (ch != LF) {
1.1 timbl 186: *p = ch; /* Put character in line */
187: if (p< &line[BIG-1]) p++;
188:
189: } else {
190: *p++ = 0; /* Terminate line */
191: p = line; /* Scan it to parse it */
192: port = 0; /* Flag "not parsed" */
193: if (TRACE) fprintf(stderr, "HTGopher: Menu item: %s\n", line);
194: gtype = *p++;
195:
196: /* Break on line with a dot by itself */
197: if ((gtype=='.') && ((*p=='\r') || (*p==0))) break;
198:
199: if (gtype && *p) {
200: name = p;
201: selector = strchr(name, TAB);
1.3 timbl 202: START(HTML_LI);
1.1 timbl 203: if (selector) {
204: *selector++ = 0; /* Terminate name */
205: host = strchr(selector, TAB);
206: if (host) {
207: *host++ = 0; /* Terminate selector */
208: port = strchr(host, TAB);
209: if (port) {
210: char *junk;
211: port[0] = ':'; /* delimit host a la W3 */
212: junk = strchr(port, TAB);
213: if (junk) *junk++ = 0; /* Chop port */
214: if ((port[1]=='0') && (!port[2]))
215: port[0] = 0; /* 0 means none */
216: } /* no port */
217: } /* host ok */
218: } /* selector ok */
219: } /* gtype and name ok */
220:
221: if (gtype == GOPHER_WWW) { /* Gopher pointer to W3 */
222: write_anchor(name, selector);
2.7 ! secret 223:
1.1 timbl 224: } else if (port) { /* Other types need port */
225: if (gtype == GOPHER_TELNET) {
226: if (*selector) sprintf(address, "telnet://%s@%s/",
2.7 ! secret 227: selector, host);
1.1 timbl 228: else sprintf(address, "telnet://%s/", host);
2.7 ! secret 229: }
! 230: else if (gtype == GOPHER_TN3270)
! 231: {
! 232: if (*selector)
! 233: sprintf(address, "tn3270://%s@%s/",
! 234: selector, host);
! 235: else
! 236: sprintf(address, "tn3270://%s/", host);
! 237: }
! 238: else { /* If parsed ok */
1.1 timbl 239: char *q;
240: char *p;
241: sprintf(address, "//%s/%c", host, gtype);
242: q = address+ strlen(address);
243: for(p=selector; *p; p++) { /* Encode selector string */
244: if (acceptable[*p]) *q++ = *p;
245: else {
246: *q++ = HEX_ESCAPE; /* Means hex coming */
247: *q++ = hex[(TOASCII(*p)) >> 4];
248: *q++ = hex[(TOASCII(*p)) & 15];
249: }
250: }
251: *q++ = 0; /* terminate address */
252: }
1.2 timbl 253: PUTS(" "); /* Prettier JW/TBL */
2.7 ! secret 254: /* Error response from Gopher doesn't deserve to
! 255: be a hyperlink. */
! 256: if (strcmp (address, "gopher://error.host:1/0"))
! 257: write_anchor(name, address);
! 258: else
! 259: PUTS(name);
! 260: PUTS("\n");
1.1 timbl 261: } else { /* parse error */
262: if (TRACE) fprintf(stderr,
263: "HTGopher: Bad menu item.\n");
1.2 timbl 264: PUTS(line);
265:
1.1 timbl 266: } /* parse error */
267:
268: p = line; /* Start again at beginning of line */
269:
270: } /* if end of line */
271:
272: } /* Loop over characters */
273:
1.2 timbl 274: END(HTML_MENU);
275: END_TARGET;
276: FREE_TARGET;
277:
1.1 timbl 278: return;
279: }
2.7 ! secret 280: /* Parse a Gopher CSO document
! 281: ** ============================
! 282: **
! 283: ** Accepts an open socket to a CSO server waiting to send us
! 284: ** data and puts it on the screen in a reasonable manner.
! 285: **
! 286: ** Perhaps this data can be automatically linked to some
! 287: ** other source as well???
! 288: **
! 289: ** Taken from hacking by Lou Montulli@ukanaix.cc.ukans.edu
! 290: ** on XMosaic-1.1, and put on libwww 2.11 by Arthur Secret,
! 291: ** secret@dxcern.cern.ch .
! 292: */
! 293:
! 294: PRIVATE void parse_cso ARGS2 (
! 295: CONST char *, arg,
! 296: HTParentAnchor *,anAnchor)
! 297: {
! 298: char ch;
! 299: char line[BIG];
! 300: char *p = line;
! 301: char *second_colon, last_char='\0';
! 302: CONST char *title;
! 303:
! 304: title = HTAnchor_title(anAnchor);
! 305: START(HTML_H1);
! 306: PUTS("CSO Search Results");
! 307: END(HTML_H1);
! 308: START(HTML_PRE);
! 309:
! 310: /* start grabbing chars from the network */
! 311: while ((ch=NEXT_CHAR) != (char)EOF)
! 312: {
! 313: if (ch != '\n')
! 314: {
! 315: *p = ch; /* Put character in line */
! 316: if (p< &line[BIG-1]) p++;
! 317: }
! 318: else
! 319: {
! 320: *p++ = 0; /* Terminate line */
! 321: p = line; /* Scan it to parse it */
! 322:
! 323: /* OK we now have a line in 'p' lets parse it and
! 324: print it */
! 325:
! 326: /* Break on line that begins with a 2. It's the end of
! 327: * data.
! 328: */
! 329: if (*p == '2')
! 330: break;
! 331:
! 332: /* lines beginning with 5 are errors,
! 333: * print them and quit
! 334: */
! 335: if (*p == '5') {
! 336: START(HTML_H2);
! 337: PUTS(p+4);
! 338: END(HTML_H2);
! 339: break;
! 340: }
! 341:
! 342: if(*p == '-') {
! 343: /* data lines look like -200:#:
! 344: * where # is the search result number and can be
! 345: * multiple digits (infinate?)
! 346: * find the second colon and check the digit to the
! 347: * left of it to see if they are diferent
! 348: * if they are then a different person is starting.
! 349: * make this line an <h2>
! 350: */
! 351:
! 352: /* find the second_colon */
! 353: second_colon = strchr( strchr(p,':')+1, ':');
! 354:
! 355: if(second_colon != NULL) { /* error check */
! 356:
! 357: if (*(second_colon-1) != last_char)
! 358: /* print seperator */
! 359: {
! 360: END(HTML_PRE);
! 361: START(HTML_H2);
! 362: }
! 363:
! 364:
! 365: /* right now the record appears with the alias
! 366: * (first line)
! 367: * as the header and the rest as <pre> text
! 368: * It might look better with the name as the
! 369: * header and the rest as a <ul> with <li> tags
! 370: * I'm not sure whether the name field comes in any
! 371: * special order or if its even required in a
! 372: * record,
! 373: * so for now the first line is the header no
! 374: * matter
! 375: * what it is (it's almost always the alias)
! 376: * A <dl> with the first line as the <DT> and
! 377: * the rest as some form of <DD> might good also?
! 378: */
! 379:
! 380: /* print data */
! 381: PUTS(second_colon+1);
! 382: PUTS("\n");
! 383:
! 384: if (*(second_colon-1) != last_char)
! 385: /* end seperator */
! 386: {
! 387: END(HTML_H2);
! 388: START(HTML_PRE);
! 389: }
! 390:
! 391: /* save the char before the second colon
! 392: * for comparison on the next pass
! 393: */
! 394: last_char = *(second_colon-1) ;
! 395:
! 396: } /* end if second_colon */
! 397: } /* end if *p == '-' */
! 398: } /* if end of line */
! 399:
! 400: } /* Loop over characters */
! 401:
! 402: /* end the text block */
! 403: PUTS("\n");
! 404: END(HTML_PRE);
! 405: PUTS("\n");
! 406: END_TARGET;
! 407: FREE_TARGET;
! 408:
! 409: return; /* all done */
! 410: } /* end of procedure */
1.1 timbl 411:
412: /* Display a Gopher Index document
2.7 ! secret 413: ** -------------------------------
! 414: */
1.1 timbl 415:
416: PRIVATE void display_index ARGS2 (
2.7 ! secret 417: CONST char *, arg,
! 418: HTParentAnchor *,anAnchor)
1.1 timbl 419: {
1.2 timbl 420:
421: START(HTML_H1);
422: PUTS(arg);
2.7 ! secret 423: PUTS(" index");
1.2 timbl 424: END(HTML_H1);
2.7 ! secret 425: START(HTML_ISINDEX);
! 426: PUTS("\nThis is a searchable Gopher index.");
! 427: PUTS(" Please enter keywords to search for.\n");
! 428:
! 429: if (!HTAnchor_title(anAnchor))
! 430: HTAnchor_setTitle(anAnchor, arg);
1.2 timbl 431:
2.7 ! secret 432: END_TARGET;
! 433: FREE_TARGET;
! 434: return;
! 435: }
! 436:
! 437:
! 438: /* Display a CSO index document
! 439: ** -------------------------------
! 440: */
! 441:
! 442: PRIVATE void display_cso ARGS2 (
! 443: CONST char *, arg,
! 444: HTParentAnchor *,anAnchor)
! 445: {
! 446: START(HTML_H1);
! 447: PUTS(arg);
! 448: PUTS(" index");
! 449: END(HTML_H1);
! 450: START(HTML_ISINDEX);
! 451: PUTS("\nThis is a searchable index of a CSO database.\n");
! 452: PUTS(" Please enter keywords to search for. The keywords that you enter");
! 453: PUTS(" will allow you to search on a person's name in the database.\n");
! 454:
1.1 timbl 455: if (!HTAnchor_title(anAnchor))
1.2 timbl 456: HTAnchor_setTitle(anAnchor, arg);
1.1 timbl 457:
1.2 timbl 458: END_TARGET;
459: FREE_TARGET;
1.1 timbl 460: return;
461: }
462:
463:
464: /* De-escape a selector into a command
465: ** -----------------------------------
466: **
467: ** The % hex escapes are converted. Otheriwse, the string is copied.
468: */
469: PRIVATE void de_escape ARGS2(char *, command, CONST char *, selector)
470: {
471: CONST char * p = selector;
472: char * q = command;
473: if (command == NULL) outofmem(__FILE__, "HTLoadGopher");
474: while (*p) { /* Decode hex */
475: if (*p == HEX_ESCAPE) {
476: char c;
477: unsigned int b;
478: p++;
479: c = *p++;
480: b = from_hex(c);
481: c = *p++;
482: if (!c) break; /* Odd number of chars! */
483: *q++ = FROMASCII((b<<4) + from_hex(c));
484: } else {
485: *q++ = *p++; /* Record */
486: }
487: }
488: *q++ = 0; /* Terminate command */
489:
490: }
491:
492:
493: /* Load by name HTLoadGopher
494: ** ============
495: **
496: ** Bug: No decoding of strange data types as yet.
497: **
498: */
1.2 timbl 499: PUBLIC int HTLoadGopher ARGS4(
500: CONST char *, arg,
501: HTParentAnchor *, anAnchor,
502: HTFormat, format_out,
503: HTStream*, sink)
1.1 timbl 504: {
505: char *command; /* The whole command */
506: int status; /* tcp return */
507: char gtype; /* Gopher Node type */
508: char * selector; /* Selector string */
509:
510: struct sockaddr_in soc_address; /* Binary network address */
511: struct sockaddr_in* sin = &soc_address;
512:
513: if (!acceptable_inited) init_acceptable();
514:
515: if (!arg) return -3; /* Bad if no name sepcified */
516: if (!*arg) return -2; /* Bad if name had zero length */
517:
518: if (TRACE) fprintf(stderr, "HTGopher: Looking for %s\n", arg);
519:
520:
521: /* Set up defaults:
522: */
523: sin->sin_family = AF_INET; /* Family, host order */
524: sin->sin_port = htons(GOPHER_PORT); /* Default: new port, */
525:
526: /* Get node name and optional port number:
527: */
528: {
529: char *p1 = HTParse(arg, "", PARSE_HOST);
530: int status = HTParseInet(sin, p1);
531: free(p1);
532: if (status) return status; /* Bad */
533: }
534:
535: /* Get entity type, and selector string.
536: */
537: {
538: char * p1 = HTParse(arg, "", PARSE_PATH|PARSE_PUNCTUATION);
539: gtype = '1'; /* Default = menu */
540: selector = p1;
541: if ((*selector++=='/') && (*selector)) { /* Skip first slash */
542: gtype = *selector++; /* Pick up gtype */
543: }
544: if (gtype == GOPHER_INDEX) {
545: char * query;
546: HTAnchor_setIndex(anAnchor); /* Search is allowed */
547: query = strchr(selector, '?'); /* Look for search string */
548: if (!query || !query[1]) { /* No search required */
1.3 timbl 549: target = HTML_new(anAnchor, format_out, sink);
1.2 timbl 550: targetClass = *target->isa;
1.1 timbl 551: display_index(arg, anAnchor); /* Display "cover page" */
2.6 timbl 552: return HT_LOADED; /* Local function only */
1.1 timbl 553: }
554: *query++ = 0; /* Skip '?' */
555: command = malloc(strlen(selector)+ 1 + strlen(query)+ 2 + 1);
556: if (command == NULL) outofmem(__FILE__, "HTLoadGopher");
557:
558: de_escape(command, selector); /* Bug fix TBL 921208 */
559:
560: strcat(command, "\t");
561:
562: { /* Remove plus signs 921006 */
563: char *p;
564: for (p=query; *p; p++) {
565: if (*p == '+') *p = ' ';
566: }
567: }
568: strcat(command, query);
2.7 ! secret 569: } else if (gtype == GOPHER_CSO) {
! 570: char * query;
! 571: HTAnchor_setIndex(anAnchor); /* Search is allowed */
! 572: query = strchr(selector, '?'); /* Look for search string */
! 573: if (!query || !query[1]) { /* No search required */
! 574: target = HTML_new(anAnchor, format_out, sink);
! 575: targetClass = *target->isa;
! 576: display_cso(arg, anAnchor); /* Display "cover page" */
! 577: return HT_LOADED; /* Local function only */
! 578: }
! 579: *query++ = 0; /* Skip '?' */
! 580: command = malloc(strlen("query")+ 1 + strlen(query)+ 2 + 1);
! 581: if (command == NULL) outofmem(__FILE__, "HTLoadGopher");
! 582:
! 583: de_escape(command, selector); /* Bug fix TBL 921208 */
! 584:
! 585: strcpy(command, "query ");
! 586:
! 587: { /* Remove plus signs 921006 */
! 588: char *p;
! 589: for (p=query; *p; p++) {
! 590: if (*p == '+') *p = ' ';
! 591: }
! 592: }
! 593: strcat(command, query);
! 594:
1.1 timbl 595:
596: } else { /* Not index */
597: command = command = malloc(strlen(selector)+2+1);
598: de_escape(command, selector);
599: }
600: free(p1);
601: }
602:
1.3 timbl 603: {
604: char * p = command + strlen(command);
605: *p++ = CR; /* Macros to be correct on Mac */
606: *p++ = LF;
607: *p++ = 0;
608: /* strcat(command, "\r\n"); */ /* CR LF, as in rfc 977 */
609: }
1.1 timbl 610:
611: /* Set up a socket to the server for the data:
612: */
613: s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
614: status = connect(s, (struct sockaddr*)&soc_address, sizeof(soc_address));
615: if (status<0){
616: if (TRACE) fprintf(stderr, "HTTPAccess: Unable to connect to remote host for `%s'.\n",
617: arg);
618: free(command);
619: return HTInetStatus("connect");
620: }
621:
622: HTInitInput(s); /* Set up input buffering */
623:
624: if (TRACE) fprintf(stderr, "HTGopher: Connected, writing command `%s' to socket %d\n", command, s);
625:
626: #ifdef NOT_ASCII
627: {
628: char * p;
629: for(p = command; *p; p++) {
630: *p = TOASCII(*p);
631: }
632: }
633: #endif
634:
635: status = NETWRITE(s, command, (int)strlen(command));
636: free(command);
637: if (status<0){
638: if (TRACE) fprintf(stderr, "HTGopher: Unable to send command.\n");
639: return HTInetStatus("send");
640: }
641:
642: /* Now read the data from the socket:
643: */
644: switch (gtype) {
645:
646: case GOPHER_HTML :
1.2 timbl 647: HTParseSocket(WWW_HTML, format_out, anAnchor, s, sink);
648: break;
1.1 timbl 649:
1.3 timbl 650: case GOPHER_GIF:
651: case GOPHER_IMAGE:
652: HTParseSocket(HTAtom_for("image/gif"),
653: format_out, anAnchor, s, sink);
654: break;
1.1 timbl 655: case GOPHER_MENU :
656: case GOPHER_INDEX :
1.3 timbl 657: target = HTML_new(anAnchor, format_out, sink);
1.2 timbl 658: targetClass = *target->isa;
1.1 timbl 659: parse_menu(arg, anAnchor);
1.2 timbl 660: break;
2.7 ! secret 661:
! 662: case GOPHER_CSO:
! 663: target = HTML_new(anAnchor, format_out, sink);
! 664: targetClass = *target->isa;
! 665: parse_cso(arg, anAnchor);
! 666: break;
! 667:
! 668: case GOPHER_MACBINHEX:
! 669: case GOPHER_PCBINHEX:
! 670: case GOPHER_UUENCODED:
! 671: case GOPHER_BINARY:
! 672: /* Specifying WWW_UNKNOWN forces dump to local disk. */
! 673: HTParseSocket (WWW_UNKNOWN, format_out, anAnchor, s, sink);
! 674: break;
! 675:
1.1 timbl 676: case GOPHER_TEXT :
677: default: /* @@ parse as plain text */
1.2 timbl 678: HTParseSocket(WWW_PLAINTEXT, format_out, anAnchor, s, sink);
2.7 ! secret 679: break;
! 680:
! 681: case GOPHER_SOUND :
! 682: HTParseSocket(WWW_AUDIO, format_out, anAnchor, s, sink);
1.2 timbl 683: break;
684:
1.1 timbl 685: } /* switch(gtype) */
1.2 timbl 686:
687: NETCLOSE(s);
688: return HT_LOADED;
1.1 timbl 689: }
1.2 timbl 690:
691: PUBLIC HTProtocol HTGopher = { "gopher", HTLoadGopher, NULL };
1.1 timbl 692:
Webmaster