Diff for /XML/nanohttp.c between versions 1.4 and 1.5

version 1.4, 1999/09/17 12:08:23 version 1.5, 1999/09/18 15:26:12
Line 1 Line 1
 /*  /*
  * nanohttp.c: minimalist HTTP implementation to fetch external subsets.   * nanohttp.c: minimalist HTTP GET implementation to fetch external subsets.
    *             focuses on size, streamability, reentrancy and portability
    *
    * This is clearly not a general purpose HTTP implementation
    * If you look for one, check:
    *         http://www.w3.org/Library/
  *   *
  * See Copyright for the status of this software.   * See Copyright for the status of this software.
  *   *
  * Daniel.Veillard@w3.org   * Daniel.Veillard@w3.org
  */   */
     
   /* TODO add compression support, Send the Accept- , and decompress on the
           fly with ZLIB if found at compile-time */
   
 #ifndef WIN32  #ifndef WIN32
 #include "config.h"  #include "config.h"
 #endif  #endif
Line 44 Line 52
 #include <sys/select.h>  #include <sys/select.h>
 #endif  #endif
   
   #ifdef STANDALONE
   #define DEBUG_HTTP
   #endif
   
 #define XML_NANO_HTTP_MAX_REDIR 10  #define XML_NANO_HTTP_MAX_REDIR 10
   
 #define XML_NANO_HTTP_CHUNK     4096  #define XML_NANO_HTTP_CHUNK     4096
Line 73  typedef struct xmlNanoHTTPCtxt { Line 85  typedef struct xmlNanoHTTPCtxt {
     char *location;     /* the new URL in case of redirect */      char *location;     /* the new URL in case of redirect */
 } xmlNanoHTTPCtxt, *xmlNanoHTTPCtxtPtr;  } xmlNanoHTTPCtxt, *xmlNanoHTTPCtxtPtr;
   
 static void xmlNanoHTTPScanURL(xmlNanoHTTPCtxtPtr ctxt, const char *URL) {  /**
    * xmlNanoHTTPScanURL:
    * @ctxt:  an HTTP context
    * @URL:  The URL used to initialize the context
    *
    * (Re)Initialize an HTTP context by parsing the URL and finding
    * the protocol host port and path it indicates.
    */
   
   static void
   xmlNanoHTTPScanURL(xmlNanoHTTPCtxtPtr ctxt, const char *URL) {
     const char *cur = URL;      const char *cur = URL;
     char buf[4096];      char buf[4096];
     int index = 0;      int index = 0;
Line 131  static void xmlNanoHTTPScanURL(xmlNanoHT Line 153  static void xmlNanoHTTPScanURL(xmlNanoHT
     }      }
     if (*cur == 0)       if (*cur == 0) 
         ctxt->path = strdup("/");          ctxt->path = strdup("/");
     else      else {
           buf[index] = 0;
         ctxt->path = strdup(cur);          ctxt->path = strdup(cur);
           while (*cur != 0) {
               if ((cur[0] == '#') || (cur[0] == '?'))
                   break;
               buf[index++] = *cur++;
           }
           buf[index] = 0;
           ctxt->path = strdup(buf);
       }   
 }  }
   
 static xmlNanoHTTPCtxtPtr xmlNanoHTTPNewCtxt(const char *URL) {  /**
    * xmlNanoHTTPNewCtxt:
    * @URL:  The URL used to initialize the context
    *
    * Allocate and initialize a new HTTP context.
    *
    * Returns an HTTP context or NULL in case of error.
    */
   
   static xmlNanoHTTPCtxtPtr
   xmlNanoHTTPNewCtxt(const char *URL) {
     xmlNanoHTTPCtxtPtr ret;      xmlNanoHTTPCtxtPtr ret;
   
     ret = (xmlNanoHTTPCtxtPtr) malloc(sizeof(xmlNanoHTTPCtxt));      ret = (xmlNanoHTTPCtxtPtr) malloc(sizeof(xmlNanoHTTPCtxt));
Line 150  static xmlNanoHTTPCtxtPtr xmlNanoHTTPNew Line 191  static xmlNanoHTTPCtxtPtr xmlNanoHTTPNew
     return(ret);      return(ret);
 }  }
   
 static void xmlNanoHTTPFreeCtxt(xmlNanoHTTPCtxtPtr ctxt) {  /**
    * xmlNanoHTTPFreeCtxt:
    * @ctxt:  an HTTP context
    *
    * Frees the context after closing the connection.
    */
   
   static void
   xmlNanoHTTPFreeCtxt(xmlNanoHTTPCtxtPtr ctxt) {
       if (ctxt == NULL) return;
     if (ctxt->hostname != NULL) free(ctxt->hostname);      if (ctxt->hostname != NULL) free(ctxt->hostname);
     if (ctxt->protocol != NULL) free(ctxt->protocol);      if (ctxt->protocol != NULL) free(ctxt->protocol);
     if (ctxt->path != NULL) free(ctxt->path);      if (ctxt->path != NULL) free(ctxt->path);
Line 164  static void xmlNanoHTTPFreeCtxt(xmlNanoH Line 214  static void xmlNanoHTTPFreeCtxt(xmlNanoH
     free(ctxt);      free(ctxt);
 }  }
   
 static void xmlNanoHTTPSend(xmlNanoHTTPCtxtPtr ctxt) {  /**
    * xmlNanoHTTPSend:
    * @ctxt:  an HTTP context
    *
    * Send the input needed to initiate the processing on the server side
    */
   
   static void
   xmlNanoHTTPSend(xmlNanoHTTPCtxtPtr ctxt) {
     if (ctxt->state & XML_NANO_HTTP_WRITE)      if (ctxt->state & XML_NANO_HTTP_WRITE)
         ctxt->last = write(ctxt->fd, ctxt->outptr, strlen(ctxt->outptr));          ctxt->last = write(ctxt->fd, ctxt->outptr, strlen(ctxt->outptr));
 }  }
   
 static int xmlNanoHTTPRecv(xmlNanoHTTPCtxtPtr ctxt) {  /**
    * xmlNanoHTTPRecv:
    * @ctxt:  an HTTP context
    *
    * Read information coming from the HTTP connection.
    * This is a blocking call (but it blocks in select(), not read()).
    *
    * Returns the number of byte read or -1 in case of error.
    */
   
   static int
   xmlNanoHTTPRecv(xmlNanoHTTPCtxtPtr ctxt) {
     fd_set rfd;      fd_set rfd;
     struct timeval tv;      struct timeval tv;
   
Line 218  static int xmlNanoHTTPRecv(xmlNanoHTTPCt Line 287  static int xmlNanoHTTPRecv(xmlNanoHTTPCt
         }          }
 #ifdef EWOULDBLOCK  #ifdef EWOULDBLOCK
         if ((ctxt->last == -1) && (errno != EWOULDBLOCK)) {          if ((ctxt->last == -1) && (errno != EWOULDBLOCK)) {
             return 0;              return(0);
         }          }
 #endif  #endif
         tv.tv_sec=10;          tv.tv_sec=10;
Line 227  static int xmlNanoHTTPRecv(xmlNanoHTTPCt Line 296  static int xmlNanoHTTPRecv(xmlNanoHTTPCt
         FD_SET(ctxt->fd, &rfd);          FD_SET(ctxt->fd, &rfd);
                   
         if(select(ctxt->fd+1, &rfd, NULL, NULL, &tv)<1)          if(select(ctxt->fd+1, &rfd, NULL, NULL, &tv)<1)
                 return 0;                  return(0);
     }      }
     return(0);      return(0);
 }  }
   
 char *xmlNanoHTTPReadLine(xmlNanoHTTPCtxtPtr ctxt) {  /**
     static char buf[4096];   * xmlNanoHTTPReadLine:
    * @ctxt:  an HTTP context
    *
    * Read one line in the HTTP server output, usually for extracting
    * the HTTP protocol informations from the answer header.
    *
    * Returns a newly allocated string with a copy of the line, or NULL
    *         which indicate the end of the input.
    */
   
   static char *
   xmlNanoHTTPReadLine(xmlNanoHTTPCtxtPtr ctxt) {
       char buf[4096];
     char *bp=buf;      char *bp=buf;
           
     while(bp - buf < 4095) {      while(bp - buf < 4095) {
         if(ctxt->inrptr == ctxt->inptr) {          if(ctxt->inrptr == ctxt->inptr) {
             if (xmlNanoHTTPRecv(ctxt) == 0) {              if (xmlNanoHTTPRecv(ctxt) == 0) {
                 if (bp == buf)                  if (bp == buf)
                     return NULL;                      return(NULL);
                 else                  else
                     *bp = 0;                      *bp = 0;
                 return buf;                  return(strdup(buf));
             }              }
         }          }
         *bp = *ctxt->inrptr++;          *bp = *ctxt->inrptr++;
         if(*bp == '\n') {          if(*bp == '\n') {
             *bp = 0;              *bp = 0;
             return buf;              return(strdup(buf));
         }          }
         if(*bp != '\r')          if(*bp != '\r')
             bp++;              bp++;
     }      }
     buf[4095] = 0;      buf[4095] = 0;
     return(buf);      return(strdup(buf));
 }  }
   
           
 static void xmlNanoHTTPScanAnswer(xmlNanoHTTPCtxtPtr ctxt, const char *line) {  /**
    * xmlNanoHTTPScanAnswer:
    * @ctxt:  an HTTP context
    * @line:  an HTTP header line
    *
    * Try to extract useful informations from the server answer.
    * We currently parse and process:
    *  - The HTTP revision/ return code
    *  - The Content-Type
    *  - The Location for redirrect processing.
    *
    * Returns -1 in case of failure, the file descriptor number otherwise
    */
   
   static void
   xmlNanoHTTPScanAnswer(xmlNanoHTTPCtxtPtr ctxt, const char *line) {
     const char *cur = line;      const char *cur = line;
   
     if (line == NULL) return;      if (line == NULL) return;
Line 330  static void xmlNanoHTTPScanAnswer(xmlNan Line 426  static void xmlNanoHTTPScanAnswer(xmlNan
     }      }
 }  }
   
 static int xmlNanoHTTPConnectAttempt(struct in_addr ia, int port)  /**
    * xmlNanoHTTPConnectAttempt:
    * @ia:  an internet adress structure
    * @port:  the port number
    *
    * Attempt a connection to the given IP:port endpoint. It forces
    * non-blocking semantic on the socket, and allow 60 seconds for
    * the host to answer.
    *
    * Returns -1 in case of failure, the file descriptor number otherwise
    */
   
   static int
   xmlNanoHTTPConnectAttempt(struct in_addr ia, int port)
 {  {
     int s=socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);      int s=socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);
     struct sockaddr_in sin;      struct sockaddr_in sin;
Line 339  static int xmlNanoHTTPConnectAttempt(str Line 448  static int xmlNanoHTTPConnectAttempt(str
     int status;      int status;
           
     if(s==-1) {      if(s==-1) {
   #ifdef DEBUG_HTTP
         perror("socket");          perror("socket");
   #endif
         return(-1);          return(-1);
     }      }
           
Line 370  static int xmlNanoHTTPConnectAttempt(str Line 481  static int xmlNanoHTTPConnectAttempt(str
         status = fcntl(s, F_SETFL, status);          status = fcntl(s, F_SETFL, status);
     }      }
     if(status < 0) {      if(status < 0) {
   #ifdef DEBUG_HTTP
         perror("nonblocking");          perror("nonblocking");
   #endif
         close(s);          close(s);
         return(-1);          return(-1);
     }      }
Line 403  static int xmlNanoHTTPConnectAttempt(str Line 516  static int xmlNanoHTTPConnectAttempt(str
             return(-1);              return(-1);
         case -1:          case -1:
             /* Ermm.. ?? */              /* Ermm.. ?? */
   #ifdef DEBUG_HTTP
             perror("select");              perror("select");
   #endif
             close(s);              close(s);
             return(-1);              return(-1);
     }      }
           
     return s;      return(s);
 }  }
     
 int xmlNanoHTTPConnectHost(const char *host, int port)  /**
    * xmlNanoHTTPConnectHost:
    * @host:  the host name
    * @port:  the port number
    *
    * Attempt a connection to the given host:port endpoint. It tries
    * the multiple IP provided by the DNS if available.
    *
    * Returns -1 in case of failure, the file descriptor number otherwise
    */
   
   static int
   xmlNanoHTTPConnectHost(const char *host, int port)
 {  {
     struct hostent *h;      struct hostent *h;
     int i;      int i;
Line 420  int xmlNanoHTTPConnectHost(const char *h Line 547  int xmlNanoHTTPConnectHost(const char *h
     h=gethostbyname(host);      h=gethostbyname(host);
     if(h==NULL)      if(h==NULL)
     {      {
   #ifdef DEBUG_HTTP
         fprintf(stderr,"unable to resolve '%s'.\n", host);          fprintf(stderr,"unable to resolve '%s'.\n", host);
   #endif
         return(-1);          return(-1);
     }      }
           
       
     for(i=0; h->h_addr_list[i]; i++)      for(i=0; h->h_addr_list[i]; i++)
     {      {
         struct in_addr ia;          struct in_addr ia;
         memcpy(&ia, h->h_addr_list[i],4);          memcpy(&ia, h->h_addr_list[i],4);
         s = xmlNanoHTTPConnectAttempt(ia, port);          s = xmlNanoHTTPConnectAttempt(ia, port);
         if(s != -1)          if(s != -1)
                 return s;              return(s);
     }      }
   
   #ifdef DEBUG_HTTP
     fprintf(stderr, "unable to connect to '%s'.\n", host);      fprintf(stderr, "unable to connect to '%s'.\n", host);
   #endif
     return(-1);      return(-1);
 }  }
   
 int xmlNanoHTTPOldFetch(const char *URL, const char *filename,  
                      char **contentType) {  
     xmlNanoHTTPCtxtPtr ctxt;  
     char buf[4096];  
     int ret;  
     int fd;  
     char *p;  
     int head;  
     int nbRedirects = 0;  
     char *redirURL = NULL;  
       
 retry:  
     if (redirURL == NULL)  
         ctxt = xmlNanoHTTPNewCtxt(URL);  
     else  
         ctxt = xmlNanoHTTPNewCtxt(redirURL);  
   
     if ((ctxt->protocol == NULL) || (strcmp(ctxt->protocol, "http"))) {  /**
         xmlNanoHTTPFreeCtxt(ctxt);   * xmlNanoHTTPOpen:
         if (redirURL != NULL) free(redirURL);   * @URL:  The URL to load
         return(-1);   * @contentType:  if available the Content-Type information will be
     }   *                returned at that location
     if (ctxt->hostname == NULL) {   *
         xmlNanoHTTPFreeCtxt(ctxt);   * This function try to open a connection to the indicated resource
         if (redirURL != NULL) free(redirURL);   * via HTTP GET.
         return(-1);   *
     }   * Returns -1 in case of failure, 0 incase of success. The contentType,
     ret = xmlNanoHTTPConnectHost(ctxt->hostname, ctxt->port);   *     if provided must be freed by the caller
     if (ret < 0) {   */
         xmlNanoHTTPFreeCtxt(ctxt);  
         if (redirURL != NULL) free(redirURL);  
         return(-1);  
     }  
     ctxt->fd = ret;  
     snprintf(buf, sizeof(buf),"GET %s HTTP/1.0\r\nhost: %s\r\n\r\n",  
              ctxt->path, ctxt->hostname);  
     ctxt->outptr = ctxt->out = strdup(buf);  
     ctxt->state = XML_NANO_HTTP_WRITE;  
     xmlNanoHTTPSend(ctxt);  
     ctxt->state = XML_NANO_HTTP_READ;  
     head = 1;  
   
     while ((p = xmlNanoHTTPReadLine(ctxt)) != NULL) {  
         if (head && (*p == 0)) {  
             head = 0;  
             ctxt->content = ctxt->inrptr;  
             break;  
         }  
         xmlNanoHTTPScanAnswer(ctxt, p);  
 if (p != NULL) printf("%s\n", p);  
     }  
     while (xmlNanoHTTPRecv(ctxt)) ;  
   
     if (!strcmp(filename, "-"))   
         fd = 0;  
     else {  
         fd = open(filename, O_CREAT | O_WRONLY);  
         if (fd < 0) {  
             xmlNanoHTTPFreeCtxt(ctxt);  
             if (redirURL != NULL) free(redirURL);  
             return(-1);  
         }  
     }  
   
 printf("Code %d, content-type '%s'\n\n",  
        ctxt->returnValue, ctxt->contentType);  
     if ((ctxt->location != NULL) && (ctxt->returnValue >= 300) &&  
         (ctxt->returnValue < 400)) {  
 printf("Redirect to: %s\n", ctxt->location);  
         if (nbRedirects < XML_NANO_HTTP_MAX_REDIR) {  
             nbRedirects++;  
             if (redirURL != NULL) free(redirURL);  
             redirURL = strdup(ctxt->location);  
             xmlNanoHTTPFreeCtxt(ctxt);  
             goto retry;  
         }  
     }  
   
     write(fd, ctxt->content, ctxt->inptr - ctxt->content);  
     xmlNanoHTTPFreeCtxt(ctxt);  
     if (redirURL != NULL) free(redirURL);  
     return(0);  
 }  
   
 void *  void *
 xmlNanoHTTPOpen(const char *URL, char **contentType) {  xmlNanoHTTPOpen(const char *URL, char **contentType) {
Line 531  xmlNanoHTTPOpen(const char *URL, char ** Line 592  xmlNanoHTTPOpen(const char *URL, char **
     int nbRedirects = 0;      int nbRedirects = 0;
     char *redirURL = NULL;      char *redirURL = NULL;
           
       if (contentType != NULL) *contentType = NULL;
   
 retry:  retry:
     if (redirURL == NULL)      if (redirURL == NULL)
         ctxt = xmlNanoHTTPNewCtxt(URL);          ctxt = xmlNanoHTTPNewCtxt(URL);
Line 555  retry: Line 618  retry:
         return(NULL);          return(NULL);
     }      }
     ctxt->fd = ret;      ctxt->fd = ret;
     snprintf(buf, sizeof(buf),"GET %s HTTP/1.0\r\nhost: %s\r\n\r\n",      snprintf(buf, sizeof(buf),"GET %s HTTP/1.0\r\nHost: %s\r\n\r\n",
              ctxt->path, ctxt->hostname);               ctxt->path, ctxt->hostname);
   #ifdef DEBUG_HTTP
       printf("-> GET %s HTTP/1.0\n-> Host: %s\n\n",
              ctxt->path, ctxt->hostname);
   #endif
     ctxt->outptr = ctxt->out = strdup(buf);      ctxt->outptr = ctxt->out = strdup(buf);
     ctxt->state = XML_NANO_HTTP_WRITE;      ctxt->state = XML_NANO_HTTP_WRITE;
     xmlNanoHTTPSend(ctxt);      xmlNanoHTTPSend(ctxt);
Line 571  retry: Line 638  retry:
         }          }
         xmlNanoHTTPScanAnswer(ctxt, p);          xmlNanoHTTPScanAnswer(ctxt, p);
   
 if (p != NULL) printf("%s\n", p);  #ifdef DEBUG_HTTP
           if (p != NULL) printf("<- %s\n", p);
   #endif
           if (p != NULL) free(p);
     }      }
   
     if ((ctxt->location != NULL) && (ctxt->returnValue >= 300) &&      if ((ctxt->location != NULL) && (ctxt->returnValue >= 300) &&
         (ctxt->returnValue < 400)) {          (ctxt->returnValue < 400)) {
 printf("Redirect to: %s\n", ctxt->location);  #ifdef DEBUG_HTTP
           printf("\nRedirect to: %s\n", ctxt->location);
   #endif
         while (xmlNanoHTTPRecv(ctxt)) ;          while (xmlNanoHTTPRecv(ctxt)) ;
         if (nbRedirects < XML_NANO_HTTP_MAX_REDIR) {          if (nbRedirects < XML_NANO_HTTP_MAX_REDIR) {
             nbRedirects++;              nbRedirects++;
Line 585  printf("Redirect to: %s\n", ctxt->locati Line 657  printf("Redirect to: %s\n", ctxt->locati
             goto retry;              goto retry;
         }          }
         xmlNanoHTTPFreeCtxt(ctxt);          xmlNanoHTTPFreeCtxt(ctxt);
   #ifdef DEBUG_HTTP
           printf("Too many redirrects, aborting ...\n");
   #endif
         return(NULL);          return(NULL);
   
     }      }
   
 printf("Code %d, content-type '%s'\n\n",      if ((contentType != NULL) && (ctxt->contentType != NULL))
        ctxt->returnValue, ctxt->contentType);          *contentType = strdup(ctxt->contentType);
   
   #ifdef DEBUG_HTTP
       if (ctxt->contentType != NULL)
           printf("\nCode %d, content-type '%s'\n\n",
                  ctxt->returnValue, ctxt->contentType);
       else
           printf("\nCode %d, no content-type\n\n",
                  ctxt->returnValue);
   #endif
   
     return((void *) ctxt);      return((void *) ctxt);
 }  }
   
   /**
    * xmlNanoHTTPRead:
    * @ctx:  the HTTP context
    * @dest:  a buffer
    * @len:  the buffer length
    *
    * This function tries to read @len bytes from the existing HTTP connection
    * and saves them in @dest. This is a blocking call.
    *
    * Returns the number of byte read. 0 is an indication of an end of connection.
    *         -1 indicates a parameter error.
    */
 int  int
 xmlNanoHTTPRead(void *ctx, void *dest, int len) {  xmlNanoHTTPRead(void *ctx, void *dest, int len) {
     xmlNanoHTTPCtxtPtr ctxt = (xmlNanoHTTPCtxtPtr) ctx;      xmlNanoHTTPCtxtPtr ctxt = (xmlNanoHTTPCtxtPtr) ctx;
Line 613  xmlNanoHTTPRead(void *ctx, void *dest, i Line 709  xmlNanoHTTPRead(void *ctx, void *dest, i
     return(len);      return(len);
 }  }
   
   /**
    * xmlNanoHTTPClose:
    * @ctx:  the HTTP context
    *
    * This function closes an HTTP context, it ends up the connection and
    * free all data related to it.
    */
 void  void
 xmlNanoHTTPClose(void *ctx) {  xmlNanoHTTPClose(void *ctx) {
     xmlNanoHTTPCtxtPtr ctxt = (xmlNanoHTTPCtxtPtr) ctx;      xmlNanoHTTPCtxtPtr ctxt = (xmlNanoHTTPCtxtPtr) ctx;
Line 622  xmlNanoHTTPClose(void *ctx) { Line 725  xmlNanoHTTPClose(void *ctx) {
     xmlNanoHTTPFreeCtxt(ctxt);      xmlNanoHTTPFreeCtxt(ctxt);
 }  }
   
 int xmlNanoHTTPFetch(const char *URL, const char *filename,  /**
                      char **contentType) {   * xmlNanoHTTPFetch:
    * @URL:  The URL to load
    * @filename:  the filename where the content should be saved
    * @contentType:  if available the Content-Type information will be
    *                returned at that location
    *
    * This function try to fetch the indicated resource via HTTP GET
    * and save it's content in the file.
    *
    * Returns -1 in case of failure, 0 incase of success. The contentType,
    *     if provided must be freed by the caller
    */
   int
   xmlNanoHTTPFetch(const char *URL, const char *filename, char **contentType) {
     void *ctxt;      void *ctxt;
     char buf[4096];      char buf[4096];
     int fd;      int fd;
Line 638  int xmlNanoHTTPFetch(const char *URL, co Line 754  int xmlNanoHTTPFetch(const char *URL, co
         fd = open(filename, O_CREAT | O_WRONLY);          fd = open(filename, O_CREAT | O_WRONLY);
         if (fd < 0) {          if (fd < 0) {
             xmlNanoHTTPClose(ctxt);              xmlNanoHTTPClose(ctxt);
               if ((contentType != NULL) && (*contentType != NULL)) {
                   free(*contentType);
                   *contentType = NULL;
               }
             return(-1);              return(-1);
         }          }
     }      }
Line 659  int main(int argc, char **argv) { Line 779  int main(int argc, char **argv) {
             xmlNanoHTTPFetch(argv[1], argv[2], &contentType);              xmlNanoHTTPFetch(argv[1], argv[2], &contentType);
         else          else
             xmlNanoHTTPFetch(argv[1], "-", &contentType);              xmlNanoHTTPFetch(argv[1], "-", &contentType);
           if (contentType != NULL) free(contentType);
     } else {      } else {
         printf("%s: minimal HTTP GET implementation\n", argv[0]);          printf("%s: minimal HTTP GET implementation\n", argv[0]);
         printf("\tusage %s [ URL [ filename ] ]\n", argv[0]);          printf("\tusage %s [ URL [ filename ] ]\n", argv[0]);

Removed from v.1.4  
changed lines
  Added in v.1.5


Webmaster