5.4 Webchat with MySQL

by Marko Riedel

5.4.1 Idea

We use the recipes of the preceding sections and the MySQL C API available through libmysqlclient to build a web chat with MySQL. We suggest you acquaint yourself with phpMyAdmin and the mysql command line interface in order to facilitate your work with MySQL.

We will work with a database that stores two types of information: session data such as the user’s name and how often her chat screen should be refreshed and the chat data, which consists of open and private channels that are protected by a password and the messages themselves.

The structure of the chat can be described by the three different screens it uses:

There are a variety of error message screens.

The login sequence is as follows: the user identifies herself in the login screen, which causes a new cookie, i.e. session identifier with a certain expiry time to be sent, selects or starts a new channel and starts using the chat through the main screen. The program must handle all of these steps. Furthermore, it must bracket its actions by a read and write of the session data, which it reads into a dictionary whose keys are the individual items.

5.4.2 MySQL tables used

There are three tables: session, channels and messages. The first holds session data. There is one entry for each session initiated by a client. The second, i.e. the table channels, records channel properties and the the third, messages, records the actual messages. We now discuss the fields of each channel in detail. Note that the string ’%d’ in the create statements is a printf-like placeholder that will be replaced by a constant.

Fields of the table session:

CREATE TABLE session (  
  sessid char(%d) NOT NULL,  
  time BIGINT,  
  login char(%d),  
  chanid BIGINT,  
  refresh INT,  
  open ENUM(’Y’, ’N’),  
  UNIQUE KEY (sessid),  
  PRIMARY KEY (sessid)  
)

Fields of the table channels:

CREATE TABLE channels (  
  id BIGINT NOT NULL AUTO_INCREMENT,  
  time BIGINT NOT NULL,  
  name char(%d),  
  password char(%d) BINARY,  
  open ENUM(’Y’, ’N’),  
  PRIMARY KEY (id)  
)

Fields of the table messages:

CREATE TABLE messages (  
  time BIGINT NOT NULL,  
  login char(%d),  
  chanid BIGINT,  
  msg BLOB,  
  PRIMARY KEY (time)  
)

5.4.3 Preliminaries

There is a wrapper script that works like the wrappers in the recipes for working with cookies and form variables.

#! /bin/sh  
#  
 
export GNUSTEP_SYSTEM_ROOT=/usr/GNUstep  
export TZ=Europe/Berlin  
. $GNUSTEP_SYSTEM_ROOT/System/Makefiles/GNUstep.sh  
 
/home/gnustep/webchat/shared_obj/ix86/linux-gnu/gnu-gnu-gnu/webchat

There is an important note concerning the makefile. We must make sure that the libmysqlclient library is linked after gnustep-base. The following makefile accomplishes this. (We have moved the code from the cookie and form recipes into the file CGIaux.m.)

include $(GNUSTEP_MAKEFILES)/common.make  
 
TOOL_NAME=webchat  
webchat_OBJC_FILES = webchat.m CGIaux.m  
 
TARGET_SYSTEM_LIBS += -lmysqlclient  
SHARED_CFLAGS     += -g  
 
include $(GNUSTEP_MAKEFILES)/tool.make

5.4.4 Implementation I: auxiliary functions and definitions

Start by defining some constants to tune the behavior of the program. You are encouraged to experiment with these. The meaning of each constant follows.

 
#include <Foundation/Foundation.h> 
#include <mysql/mysql.h> 
 
#include ”CGIaux.h” 
 
#define CHATCGI ”/cgi-bin/webchat.sh” 
 
#define MYSQL_USER        ”chatuser” 
#define MYSQL_PASSWORD    ”webchat” 
#define CHATDB                ”chatdb” 
 
#define LEN_LOGIN                 16 
#define LEN_SESSID                16 
#define LEN_CHANNEL              32 
#define LEN_MSG                   256 
#define LEN_PASSWORD             32 
 
#define DISPLAY_MAX              10 
 
#define SESSPERSIST           10*60 
#define CHANPERSIST (SESSPERSIST+5*60) 
#define MSGPERSIST (CHANPERSIST+5*60)

A series of useful macros follows. The macro MYSQL_ERR turns the most recent error message from MySQL into an NSString object. The macros FMT1 to FMT6 help simplify the use of strings obtained from a format string, which occurs a lot in this application.

 
#define MYSQL_ERR [NSString stringWithCString: \ 
                                      mysql_error(&mysql)] 
 
#define FMT1(_f, _a1) \ 
       [NSString stringWithFormat:_f, _a1] 
#define FMT2(_f, _a1, _a2) \ 
       [NSString stringWithFormat:_f, _a1, _a2] 
#define FMT3(_f, _a1, _a2, _a3) \ 
       [NSString stringWithFormat:_f, _a1, _a2, _a3] 
#define FMT4(_f, _a1, _a2, _a3, _a4) \ 
       [NSString stringWithFormat:_f, _a1, _a2, _a3, _a4] 
#define FMT5(_f, _a1, _a2, _a3, _a4, _a5) \ 
       [NSString stringWithFormat:_f, _a1, _a2, _a3, _a4, _a5] 
#define FMT6(_f, _a1, _a2, _a3, _a4, _a5, _a6) \ 
       [NSString stringWithFormat:_f, _a1, _a2, _a3, _a4, _a5, _a6]

We now define a set of auxiliary functions that we will use in the main procedure of the script. The function Empty tests whether a string object is empty, i.e. whether it is nil or has length zero.

 
BOOL inline Empty(NSString *str) 
{ 
    return (str==nil ∣∣ ![str length]); 
}

There are peculiarities pertaining to the way messages are being stored and retrieved that have to be taken into account. A message may contain characters that we would have to escape before the message can be given to MySQL. Furthermore, what if a message contains HTML tags? These could hamper the functionality of the message display screen. We avoid all these problems by encoding each byte of the message in two hexadecimal digits and store this safe representation. We reconstruct the original byte ’B’ when we decode the message and output it as the HTML entity ’&#B;’, which assures that even HTML tags will be displayed literally (as opposed to being interpreted as markup).

The function EncodeMsg iterates over each byte of the string and uses the upper and lower four bits as an index into a string of hexadecimal digits. It appends the two digits to the result. We are done when we have reached the end of the string. Of course this doubles the length of the string that we will store.

 
NSString *EncodeMsg(NSString *msg) 
{ 
    NSString *result = @””; 
    const char *ptr = [msg cString], 
        *xdigits = ”0123456789ABCDEF”; 
 
    while(*ptr){ 
        unsigned char item = *ptr++; 
        result = [result stringByAppendingFormat:@”%c%c”, 
                          xdigits[item/16], xdigits[item%16]]; 
    } 
 
    return result; 
}

The function DecodeMsgIntoEntities also iterates over the individual bytes of its argument. It processes two bytes, i.e. digits, at a time and depends on digits and letters being in sequence in the ASCII string encoding. If it finds a decimal digit, then its value is given by the difference between the digit and the character ’0’; the value of a hexadecimal digit is ten plus the difference between the digit and the character ’A’. It remains to compute the reconstructed byte and append the appropriate HTML entity to the result.

 
NSString *DecodeMsgIntoEntities(NSString *msg) 
{ 
    NSString *result = @””; 
    const char *ptr = [msg cString]; 
 
    while(ptr[0] && ptr[1]){ 
        int upper = (isdigit(ptr[0]) ? 
                      ptr[0]-’0’ : ptr[0]-’A’+10); 
        int lower = (isdigit(ptr[1]) ? 
                      ptr[1]-’0’ : ptr[1]-’A’+10); 
        result = [result stringByAppendingFormat:@”&#%d;”, 
                          upper*16+lower]; 
        ptr += 2; 
    } 
 
    return result; 
}

The next two functions encapsulate two chunks of HTML that is output at several places in the program. The function Footer outputs a separator (line), followed by a link to the author’s home page, followed by the name of the program, the host name and the current date. All of these should make it easy for the user to locate the server that is currently being used.

 
NSString *Footer() 
{ 
    NSProcessInfo *pinfo = [NSProcessInfo processInfo]; 
 
    NSString *fstr = 
        FMT3(@<HR>\n” 
             @<ADDRESS>%@_by_marko_riedel,_” 
             @<A_HREF=” 
             @”http://www.geocities.com/markoriedelde>\n” 
             @”http://www.geocities.com/markoriedelde</A>\n” 
             @”at_%@,_%@</ADDRESS>\n”, 
             [pinfo processName], [pinfo hostName], 
             [[NSDate date] description]); 
 
    return fstr; 
}

There are several screens, including those for error messages, where the user should be given the chance to start over. This is what the output of the function BackToLogin does. It outputs a form containing a single button and an important hidden field: cmd, i.e. “command.” This is set to “restart” and lets the user restart the session. We shall see later how commands are processed. In fact “restart” is not the only command; there is also “display” and “enter,” which display the two frames of the main screen, respectively.

 
NSString *BackToLogin() 
{ 
    NSString *btologin = 
        @<BR><FORM_TARGET=_top_METHOD=POST_” 
        @”ACTION=webchat.sh>\n” 
        @<INPUT_TYPE=HIDDEN_NAME=cmd_VALUE=restart>\n” 
        @<INPUT_TYPE=SUBMIT_” 
        @”VALUE=\”Back_to_login\>\n” 
        @</FORM>\n”; 
 
    return btologin; 
}

The function ErrmsgAndExit plays an important role in the program. We do extensive error checking (quite possibly a bit more than necessary) and we need a function that outputs an error message and lets the user start over. This is the purpose of ErrmsgAndExit. It is invoked with a short description of the error and a string that gives the details of the error that occurred. It also outputs the restart button.

 
void ErrmsgAndExit(NSString *desc, NSString *detail) 
{ 
    NSString *body = 
        FMT5(@<HTML><HEAD>\n” 
             @<TITLE>%@</TITLE>\n” 
             @</HEAD><BODY_BGCOLOR=white>\n” 
             @<H1>%@</H1>\n” 
             @”%@\n%@\n%@\n” 
             @</BODY></HTML>\n”, 
             desc, desc, detail, 
             BackToLogin(), Footer()); 
 
    printf(”Content-type:_text/html\r\n\r\n”); 
    printf(”%s”, [body cString]); 
 
    exit(1); 
}

One error condition that occurs quite frequently is when we expected an alphanumeric string and received something else, or that the length of the string is not what we require. The function CheckForAlNum tests for these conditions. First it verifies that the string has the right length and aborts with an error otherwise. Next it iterates over the characters that make up the string, testing each character with isalnum in turn. If it does not get to the terminating null byte then there was an illegal character. In this case it outputs a descriptive error message.

 
void CheckForAlNum(NSString *toCheck, NSString *desc, 
                    int min, int max) 
{ 
    int len = (toCheck==nil ? 0 : [toCheck length]); 
    NSString *err, *aux; 
 
    if(len<min ∣∣ len>max){ 
        err = FMT3(@”%@_empty_or_too_long;_” 
                    @”min_%d,_max_%d_characters.”, 
                    desc, min, max); 
        aux = FMT1(@”Got_’%@’.”, toCheck); 
        ErrmsgAndExit(err, aux); 
    } 
 
    const char *ptr = [toCheck cString], *base = ptr; 
    while(isalnum(*ptr++)); 
    if(*(ptr-1)){ 
        err = FMT1(@”Illegal_character_” 
                    @”in_%@;_must_be_alphanumeric.”, desc); 
        aux = FMT2(@”Got_’&#%d’_” 
                    @”at_position_%d.”, 
                    (int)*(ptr-1), ptr-base); 
        ErrmsgAndExit(err, aux); 
    } 
}

We now meet one of the most important functions of the program, i.e. OutputPageAndExit. Recall that we store the session data in the MySQL database. Whatever we do, the first step must be to read those session data into the session dictionary if present, and the last, to record the current contents of the dictionary. The read only occurs once, i.e. at the beginning of the program. The update may be invoked at different exit points of the program, which is why we put it into the function OutputPageAndExit.

There are two steps to this function: first, write the session data, and second, output the page and exit. The first step starts by building the MySQL query. It says to update the table session with the values from the session dictionary, which contains the session data that we retrieved from MySQL. Only the time is not set from a dictionary value. It is set to the current time, because that is the time of the most recent activity in the session.

 
void OutputPageAndExit(MYSQL mysql, NSMutableDictionary *session, 
                        NSString *title, NSString *frames, 
                        NSString *body, NSString *bodyargs) 
{ 
    NSString *query = 
        FMT6(@”UPDATE_session_SET_” 
             @”time_=_’%lu’,_login=’%@’,_refresh=’%@’,_” 
             @”chanid_=_’%@’,_open=’%@’_” 
             @”WHERE_sessid=’%@’”, 
             (long)[[NSDate date] timeIntervalSince1970], 
             [session objectForKey:@”login”], 
             [session objectForKey:@”refresh”], 
             [session objectForKey:@”chanid”], 
             [session objectForKey:@”open”], 
             [session objectForKey:@”sessid”]);

The end of the first step is to actually do the query and generate an error message if it fails.

 
    if(mysql_query(&mysql, [query cString])){ 
        NSString *err = 
            FMT1(@”Couldn’t_write_session_data:_%@”, query); 
        ErrmsgAndExit(err, MYSQL_ERR); 
    }

Step two is easy: output the page as described by the arguments. We include the footer. The argument bodyargs can be used to set BODY properties, such as an “onLoad” that starts a timer or sets the focus.

 
    printf(”Content-type:_text/html\r\n”); 
    printf(”Pragma:_no-cache\r\n\r\n”); 
 
    printf(<HTML><HEAD><TITLE>%s</TITLE>\n%s</HEAD>\n”, 
           [title cString], [frames cString]); 
    printf(<BODY%s>\n”, [bodyargs cString]); 
    printf(”%s”, [body cString]); 
    printf(”%s\n”, [Footer() cString]); 
    printf(</BODY></HTML>\n”); 
 
    exit(0); 
}

The function SetCookie is as important as OutputPageAndExit. There are three cases where it must be used. We must generate and output a new cookie if the script did not receive one, which signals the start of a new session. We should reset the expiry time of the cookie when the session is restarted. Finally, the expiry time must also be updated when the user enters a message, which indicates activity on the session.

The expiry time of the cookie should be SESSPERSIST seconds from now. We build the cookie dictionary with the expiry time, the path and most importantly, the session id, convert it to a string, then to a C string, and output the result.

 
void SetCookie(NSString *sessid) 
{ 
    NSDate *expDate = 
        [NSDate dateWithTimeIntervalSinceNow:SESSPERSIST]; 
 
    NSMutableDictionary *cdict = 
        [NSMutableDictionary 
            dictionaryWithObjectsAndKeys:sessid, @”SESSID”, 
            expDate, @”expires”, @CHATCGI, @”path”, nil]; 
 
    printf([[cdict cookieToString] cString]); 
}

It remains to discuss two auxiliary functions that encapsulate MySQL queries. The first of these, FirstFieldsFromResult, takes a MySQL result consisting of some number of rows, of which we are only interested in the first field. It produces an array of those fields. It fetches the rows in turn, converts the C string in the first field to an NSString and stores the latter in an array, which it returns when it has processed all the rows.

 
NSMutableArray *FirstFieldsFromResult(MYSQL_RES *result) 
{ 
    MYSQL_ROW row; 
    NSMutableArray *data = 
        [NSMutableArray arrayWithCapacity:1]; 
 
    while((row=mysql_fetch_row(result))!=NULL){ 
        NSString *fstr = 
            [NSString stringWithCString:row[0]]; 
        [data addObject:fstr]; 
    } 
 
    return data; 
}

The second auxiliary function for use with MySQL is RowsFromQuery, which also processes rows resulting from a query. It returns an array of dictionaries, one dictionary per row. The keys of the dictionary are the names of the fields, and the values are the actual data. First we make the query and signal any errors that may have occurred.

 
NSMutableArray *RowsFromQuery(MYSQL mysql, NSString *query) 
{ 
    if(mysql_query(&mysql, [query cString])){ 
        NSString *err = FMT1(@”Query_failed:_%@”, query); 
        ErrmsgAndExit(err, MYSQL_ERR); 
    }

Next we obtain the query result so that we may fetch the rows, again checking for possible errors.

 
    MYSQL_RES *result; 
    if((result=mysql_use_result(&mysql))==NULL){ 
        NSString *err = FMT1(@”No_results_for_query:_%@”, query); 
        ErrmsgAndExit(err, MYSQL_ERR); 
    }

We need the field names, which are the keys to the row dictionaries and need only be fetched once. We obtain the number of fields and a pointer to the fields themselves. We convert each field name to an NSString and add it to the array of field names. The name of an element at position k of a row is given by the name at position k in the array of field names.

 
    unsigned int ftotal = mysql_field_count(&mysql), fcur; 
    MYSQL_FIELD *fields = mysql_fetch_fields(result); 
    NSString *fstr; 
 
    NSMutableArray 
        *fnames = [NSMutableArray arrayWithCapacity:ftotal]; 
    for(fcur=0; fcur<ftotal; fcur++){ 
        fstr = [NSString stringWithCString:fields[fcur].name]; 
        [fnames addObject:fstr]; 
    }

We can process the rows now that we have the field names. We fetch the total number of rows and initialize a mutable array having this capacity. It will hold dictionaries.

 
    MYSQL_ROW row; 
    my_ulonglong rtotal = mysql_num_rows(result); 
 
    NSMutableArray 
        *data = [NSMutableArray arrayWithCapacity:rtotal];

Now iterate over the rows. Create a dictionary with the right capacity for each row. Then iterate over the fields, storing them as NSString objects. The key is obtained from the corresponding entry in the array of field names. Add the row dictionary to the data array once all the fields have been processed.

 
    while((row=mysql_fetch_row(result))!=NULL){ 
        NSMutableDictionary *rowData = 
            [NSMutableDictionary dictionaryWithCapacity:ftotal]; 
        for(fcur=0; fcur<ftotal; fcur++){ 
            fstr = [NSString stringWithCString:row[fcur]]; 
            [rowData setObject:fstr 
                      forKey:[fnames objectAtIndex:fcur]]; 
        } 
        [data addObject:rowData]; 
    }

It remains to free the MySQL result and return the array of dictionaries.

 
    mysql_free_result(result); 
 
    return data; 
 
}

This is the end of the first section.

5.4.5 Implementation II: main

We are now ready to discuss the function main, which does the actual work of generating the different screens and interacting with the database. We declare commonly used variables that hold a MySQL query, an error message and an error message description, repectively. The first thing to do is to initialize the MySQL client library and connect to the local host as user MYSQL_USER, with the password MYSQL_PASSWORD. We send any errors to the client, which is how all errors will be handled.

 
int main(int argc, char** argv, char **env) 
{ 
    NSAutoreleasePool *pool = [NSAutoreleasePool new]; 
 
    NSString *query, *err, *aux; 
    MYSQL mysql; 
    MYSQL_RES *result; 
 
    mysql_init(&mysql); 
 
    if(mysql_real_connect(&mysql,”localhost”, 
                           MYSQL_USER, MYSQL_PASSWORD, 
                           NULL, 0, NULL, 0)==NULL){ 
        ErrmsgAndExit(@”MySQL_connect_failed”, MYSQL_ERR); 
    }

We ask for the list of databases that match the regular expression CHATDB and process any errors. We convert the result into an array of database names and free the MySQL result.

 
    if((result=mysql_list_dbs(&mysql, CHATDB))==NULL){ 
        ErrmsgAndExit(@”Couldn’t_list_databases”, MYSQL_ERR); 
    } 
    NSMutableArray *databases = FirstFieldsFromResult(result); 
    mysql_free_result(result);

We have a problem if there was no database that matched the pattern. We try to create it if this is the case and abort with an error message if the create query failed.

 
    if([databases containsObject:@CHATDB]==NO){ 
        query = [NSString stringWithFormat:@”CREATE_DATABASE_%s”, 
                           CHATDB]; 
        if(mysql_query(&mysql, [query cString])){ 
            err = FMT1(@”Couldn’t_create_database:_%s”, CHATDB); 
            ErrmsgAndExit(err, MYSQL_ERR); 
        } 
    }

We can select the chat database once we know that it exists.

 
    if(mysql_select_db(&mysql, CHATDB)){ 
        err = FMT1(@”Couldn’t_select_database:_%s”, CHATDB); 
        ErrmsgAndExit(err, MYSQL_ERR); 
    }

We need to verify that the three tables session, channel and messages are present. Hence we ask MySQL for a list of tables. We may use FirstFieldsFromResult because every row of the result contains exactly one field.

 
    if((result=mysql_list_tables(&mysql, NULL))==NULL){ 
        ErrmsgAndExit(@”Couldn’t_list_tables”, MYSQL_ERR); 
    } 
    NSMutableArray *tables = FirstFieldsFromResult(result); 
    mysql_free_result(result);

What follows is a very important declaration. We declare an array that holds the names of the tables that must be present, and a second array containing CREATE statements for each table for use if the table is not present. The third array holds the maximum age of an entry for each table. These three arrays use the same order.

 
    NSString *tnames[] = { @”session”, @”channels”, @”messages”, nil }; 
    NSString *tcreate[] = { 
        FMT2(@”CREATE_TABLE_session_(” 
             @”sessid_char(%d)_NOT_NULL,” 
             @”time_BIGINT,” 
             @”login_char(%d),” 
             @”chanid_BIGINT,” 
             @”refresh_INT,” 
             @”open_ENUM(’Y’,_’N’),” 
             @”UNIQUE_KEY_(sessid),” 
             @”PRIMARY_KEY_(sessid)” 
             @”)”, LEN_SESSID, LEN_LOGIN), 
        FMT2(@”CREATE_TABLE_channels_(” 
             @”id_BIGINT_NOT_NULL_AUTO_INCREMENT,” 
             @”time_BIGINT_NOT_NULL,” 
             @”name_char(%d),” 
             @”password_char(%d)_BINARY,” 
             @”open_ENUM(’Y’,_’N’),” 
             @”PRIMARY_KEY_(id)” 
             @”)”, LEN_CHANNEL, LEN_PASSWORD), 
        FMT1(@”CREATE_TABLE_messages_(” 
             @”time_BIGINT_NOT_NULL,” 
             @”login_char(%d),” 
             @”chanid_BIGINT,” 
             @”msg_BLOB,” 
             @”PRIMARY_KEY_(time)” 
             @”)”, LEN_LOGIN) 
    }; 
    long persists[] = { 
        SESSPERSIST, 
        CHANPERSIST, 
        MSGPERSIST 
    };

We iterate over these three arrays in parallel. If a table is not present in the list of tables that we obtained from MySQL, then it must be created, which we immediately try to do, checking for errors as we go.

 
    int tindex = 0; 
 
    while(tnames[tindex]!=nil){ 
        if([tables containsObject:tnames[tindex]]==NO){ 
            if(mysql_query(&mysql, [tcreate[tindex] cString])){ 
                err = FMT1(@”Query_failed:_%@”, tcreate[tindex]); 
                ErrmsgAndExit(err, MYSQL_ERR); 
            } 
        }

The second half of the loop does what we may call house-keeping. It erases records that are too old. Errors are sent to the client. (We probably should have put the DELETE in an else clause, because there is nothing to delete from a new table, although it can do no harm.)

 
        long now = 
            [[NSDate date] timeIntervalSince1970]; 
        query = FMT2(@”DELETE_FROM_%@_WHERE_time<%ld”, 
                      tnames[tindex], now-persists[tindex]); 
        if(mysql_query(&mysql, [query cString])){ 
            err = FMT1(@”Delete_failed:_%@”, query); 
            ErrmsgAndExit(err, MYSQL_ERR); 
        } 
        tindex++; 
    }

We are now ready to do some actual processing. We parse any GET or POST variables that were passed to the script. We also parse the cookie, if there was one, and look up the session id in the cookie dictionary.

 
    NSMutableDictionary *params = 
        [[NSMutableDictionary alloc] initWithCGI]; 
    NSString *cmd = [params objectForKey:@”cmd”]; 
 
    NSMutableDictionary *cvals = 
        [[NSMutableDictionary alloc] initWithCookie]; 
    NSString *sessid = [cvals objectForKey:@”SESSID”];

If there was no session id in the cookie then we are either starting a new session or we are being called to display a portion of the main screen, but the cookie has expired. We send an explanatory error message in the latter case.

 
    if(sessid==nil){ 
        if(cmd!=nil && 
           [cmd isEqualToString:@”display”]==YES ∣∣ 
           [cmd isEqualToString:@”enter”]==YES){ 
            err = FMT1(@”Session_timed_out,_date_is_%@.”, 
                        [[NSDate date] description]); 
            aux = FMT1(@”Command_was:_%@”, cmd); 
            ErrmsgAndExit(err, aux); 
        }

If there was no timeout but there is no session id, then we must generate and initialize a new one that should preferably not be easily guessable, so that no user can join someone else’s session by guessing an actual value of a session id that is in use and sending it as a cookie. We seed the random number generator and generate each character of the key in turn, asking for a random number from the interval [0,26 + 26 + 10 - 1]. Values from [0,26 - 1] yield lower case letters, values from [26,26 + 26 - 1], upper case letters and values from [26 + 26,26 + 26 + 10 - 1], digits. We append the character obtained in this manner to the session id.

 
        int pos; 
 
        srand48(time(NULL)); 
        sessid = @””; 
 
        for(pos=0; pos<LEN_SESSID; pos++){ 
            char item; 
            int choice = lrand48() % (26+26+10); 
            item = (choice < 26 ? ’a’+choice : 
                     (choice< 52 ? ’A’+choice-26 : 
                      ’0’+choice-26-26)); 
            sessid = 
                [sessid stringByAppendingFormat:@”%c”, item]; 
        }

But it is not enough merely to generate the key, we must also store it in the sessions table and initialize the remaining fields of the session. We use an INSERT query for this purpose. It stores the session id and the current time, leaving the other fields empty. Errors are sent to the client; e.g. the query will fail in the admittedly unlikely case that we generated a session id that is already in the table, because we declared the corresponding field to be unique. We are done initializing once we output the cookie for storage in the client with the session id using Set-Cookie.

 
        query = FMT2(@”INSERT_INTO_session_(sessid,_time)_” 
                      @”VALUES_(’%@’,_’%ld’)”, 
                      sessid, 
                      (long)[[NSDate date] timeIntervalSince1970]); 
        if(mysql_query(&mysql, [query cString])){ 
            err = FMT1(@”Couldn’t_initialize_session:_%@” 
                        @<BR>Try_again.”, query); 
            ErrmsgAndExit(err, MYSQL_ERR); 
        } 
 
        SetCookie(sessid); 
    }

We may now be certain that we have a valid session id, even if it has only just been generated. Hence we may read the session data into a dictionary. This is done with a SELECT query that selects all fields from those records that match the session id. There should be exactly one such record; everything else is an error. We extract the session dictionary.

 
    NSString *title, *frames, *body, *bodyargs; 
 
    query = FMT1(@”SELECT_*FROM_session_WHERE_sessid=’%@’”, 
                 sessid); 
    NSMutableArray *sessdata = RowsFromQuery(mysql, query); 
    if([sessdata count]!=1){ 
        err = FMT2(@”Found_%d_entries_for_%@.”, 
                    [sessdata count], sessid); 
        ErrmsgAndExit(err, query); 
    } 
    NSMutableDictionary *session = [sessdata objectAtIndex:0];

We must respond to the command “restart” before we do any additional processing. We clear all fields in the session dictionary and output the cookie, which will now last an additional SESSPERSIST seconds. Remember that the session time stamp will be set by OutputPageAndExit.

 
    if([cmd isEqualToString:@”restart”]){ 
        [session setObject:@”” forKey:@”login”]; 
        [session setObject:@”” forKey:@”refresh”]; 
        [session setObject:@”” forKey:@”open”]; 
        [session setObject:@”” forKey:@”chanid”]; 
        SetCookie(sessid); 
    }

We have now come to the point where we output the first of our several screens, which is the login screen. We output the login form if there is no user name in the session dictionary and we did not receive one via a GET or POST. The form contains a table with three rows, i.e. name, refresh and session type. There is a submit button.

 
    if(Empty([session objectForKey:@”login”]) && 
       Empty([params objectForKey:@”login”])){ 
        title = @”Chat_login”; 
        frames = @””; 
        body = @<FONT_SIZE=24><B>Login</B></FONT>\n” 
            @<FORM_METHOD=POST_ACTION=webchat.sh>\n” 
            @<TABLE>\n” 
            @<TR><TD>Name:</TD>\n” 
            @<TD><INPUT_TYPE=TEXT_NAME=login>\n” 
            @</TD></TR>\n” 
            @<TR><TD>Refresh_(in_s)</TD>\n” 
            @<TD>\n” 
            @<INPUT_TYPE=TEXT_NAME=refresh>\n” 
            @</TD></TR>\n” 
            @<TR><TD>Session_type:</TD>\n” 
            @<TD>\n” 
            @<SELECT_NAME=open>\n” 
            @<OPTION_SELECTED_VALUE=Y>__open_</OPTION>\n” 
            @<OPTION_VALUE=N>_private_</OPTION>\n” 
            @</SELECT>\n” 
            @</TD></TR>\n” 
            @</TABLE>\n” 
            @<INPUT_TYPE=SUBMIT_VALUE=Continue>\n” 
            @</FORM>\n”; 
        bodyargs = @””; 
        OutputPageAndExit(mysql, session, 
                           title, frames, body, bodyargs); 
    }

The remaining screens require a time stamp, so we set it here for use in the rest of the program.

 
    long now = 
        [[NSDate date] timeIntervalSince1970];

The first case was an empty session and no CGI variables, which lead to the login screen. The second case is an empty session and the appropriate CGI variables. We set the flag readParams in this case and copy the variables from the CGI dictionary to the session dictionary.

 
    BOOL readParams = NO; 
    if(Empty([session objectForKey:@”login”])){ 
        readParams = YES; 
        [session setObject:[params objectForKey:@”login”] 
                 forKey:@”login”]; 
        [session setObject:[params objectForKey:@”refresh”] 
                 forKey:@”refresh”]; 
        [session setObject:[params objectForKey:@”open”] 
                 forKey:@”open”]; 
    }

We may now read the settings that correspond to the data obtained from the login screen.

 
    NSString *login = [session objectForKey:@”login”]; 
    int refresh = [[session objectForKey:@”refresh”] intValue]; 
    char open = ([[session objectForKey:@”open”] 
                      isEqualToString:@”Y”] ? ’Y’ : ’N’);

There is error checking to do if we obtained those settings from the CGI dictionary. We verify that the login name is not empty and consists of alphanumeric characters only. The refresh should be set to a positive interval.

 
    if(readParams==YES){ 
        CheckForAlNum(login, @”Login”, 1, LEN_LOGIN); 
 
        if(refresh<1){ 
            err = @”Refresh_must_be_positive.”; 
            aux = FMT1(@”Got_’%d’.”, refresh); 
            ErrmsgAndExit(err, aux); 
        } 
    }

We now have the data that correspond to the login screen. The channel screen is next. We generate this screen if the channel id has not been set and is not among the CGI parameters.

 
    NSString *chanid = [session objectForKey:@”chanid”]; 
    int numericCID = [chanid intValue]; 
 
    if(!numericCID && Empty([params objectForKey:@”channel”])){

We will generate the output line by line or item per item in all cases that follow and only join those lines when everything has been generated. We do not send HTML as it is created. This is so that there is no mixing between regular output and error messages.

There are two cases that correspond to open and private channels. Both contain forms. They have in common that the first form contains a text input where the name of the channel may be entered. This can be used to select an existing channel or to create a new one.

 
        NSString *line; 
        NSMutableArray *lines = 
            [NSMutableArray arrayWithCapacity:8]; 
 
        title = @”Chat_login:_select_or_start_channel”; 
        body = @<FONT_SIZE=24>\n” 
            @<B>Login:_select_or_start_channel</B>\n” 
            @</FONT>\n” 
            @<FORM_METHOD=POST_ACTION=webchat.sh>\n” 
            @”Channel:_<INPUT_TYPE=TEXT_NAME=channel>\n”;

Now is where the two cases (open vs. private) diverge. We output a password field when the user has selected private channels. The submit button is again common to both cases.

 
        if(open==’N’){ 
            line = @”Password:_<INPUT_TYPE=PASSWORD_NAME=password>; 
            [lines addObject:line]; 
        } 
        line = @<INPUT_TYPE=SUBMIT_VALUE=Enter>\n</FORM>; 
        [lines addObject:line];

We wish to present a menu of currently open channels when the user has selected open channels. We find those channels with a SELECT query. We group the messages by channel id and select the channel name, the number of messages and the newest time stamp from the table channels, which is updated every time a message is recorded. The where clause binds the channel name from the table channels to the channel id from the table messages. The result is a set of rows with three fields, that hold the channel name, the number of messages for the channel, and the newest message time stamp for the channel. We must check the open property so that our menu will not display private channels. We let MySQL do the sorting. The result will list channels according to their time stamps, with the channel that most recently had any activity first.

 
        if(open==’Y’){ 
            query = @”SELECT_” 
                @”name,_” 
                @”COUNT(chanid),_” 
                @”channels.time_” 
                @”FROM_channels,_messages_” 
                @”WHERE_channels.id=messages.chanid_” 
                @”AND_open=’Y’_” 
                @”GROUP_BY_messages.chanid_” 
                @”ORDER_BY_channels.time_DESC”; 
            NSMutableArray *chanData = 
                RowsFromQuery(mysql, query); 
            NSEnumerator *chanEnum = 
                [chanData objectEnumerator]; 
            NSMutableDictionary *channel;

We output the channel menu as a table, iterating over the channels with the enumerator.

 
            line = @<TABLE_BORDER_BGCOLOR=white>\n”; 
            [lines addObject:line]; 
 
            while((channel = [chanEnum nextObject])!=nil){

Every entry in the menu is a form that contains a single button, which selects the respective channel. We must convert the time stamp into a readable date format, which we do with NSDate’s description. The number of messages is given by the appropriate field from the query.

 
                [lines addObject: 
                            @<FORM_METHOD=POST_ACTION=webchat.sh”]; 
 
                long tval = 
                     atol([[channel objectForKey:@”time”] 
                              cString]); 
                NSString *dateStr = 
                     [[NSDate dateWithTimeIntervalSince1970:tval] 
                         description]; 
                NSString *count = 
                     [channel 
                         objectForKey:@”COUNT(chanid)”];

An entry in the menu has three fields: the submit button, the time stamp, and the number of messages. The submit button is labeled with the name of the channel. This completes the form for the current entry, which we also could have put inside a single “TD” item rather than wrapping the entire row in the form.

 
                line = FMT4(@<TR><TD>\n” 
                             @<INPUT_TYPE=SUBMIT_” 
                             @”NAME=channel_” 
                             @”VALUE=%@></TD>\n<TD> 
                             @”%@</TD>\n<TD>_%@_message%@_” 
                             @</TD></TR>, 
                             [channel objectForKey:@”name”], 
                             dateStr, count, 
                             ([count isEqualToString:@”1”]==NO ? 
                              @”s” : @””)); 
                [lines addObject:line]; 
 
                [lines addObject:@</FORM>]; 
            }

The special case of the open chanel menu ends with a closing tag for the table. The last step is to output everything and exit the program. This step is again common to open and private channels.

 
            line = @</TABLE>\n”; 
            [lines addObject:line]; 
        } 
 
        frames = @””; 
        body = 
            [body stringByAppendingString: 
                       [lines componentsJoinedByString:@\n”]]; 
        bodyargs = @””; 
        OutputPageAndExit(mysql, session, 
                           title, frames, body, bodyargs); 
    }

If we have got this far, but do not have a channel id, then the user must have submitted a channel name and possibly a password using precisely the form whose generation we just described. Hence we extract the channel name and the password form the dictionary of CGI parameters. There are two cases: either the channel exists already, and we select it, otherwise, we create a new channel and select it. (We pre-declare some variables that will hold the dictionary for the current channel and its name.)

 
    NSMutableArray *chanLookup; 
    int chanCount; 
    NSMutableDictionary *chanRecord; 
    NSString *channel; 
 
    if(!numericCID){ 
        NSString *candidate = [params objectForKey:@”channel”]; 
        NSString *password = [params objectForKey:@”password”];

We will only accept channel names and passwords that are alphanumeric and have the right length.

 
        CheckForAlNum(candidate, @”Channel”, 4, LEN_CHANNEL); 
        if(open==’N’){ 
            CheckForAlNum(password, @”Password”, 4, LEN_PASSWORD); 
        }

We need to know whether this is a new channel or not, so we send the appropriate query to MySQL, using the channel name to select matching channels. We have a duplicate channel if we get more than one record with this name. (This should not happen.)

 
        query = [NSString stringWithFormat:@”SELECT_” 
                           @”id,_open,_name_” 
                           @”FROM_channels_WHERE_name=’%@’_”, 
                           candidate]; 
 
        chanLookup = RowsFromQuery(mysql, query); 
        chanCount = [chanLookup count]; 
 
        if(chanCount>1){ 
            err = FMT1(@”Duplicate_channel:_%@.”, candidate); 
            aux = FMT1(@”Count_was_%d.”, chanCount); 
            ErrmsgAndExit(err, aux); 
        }

If we obtained a single record, then the user is trying to join an existing channel, in which case we must verify that the user’s choice of an open or private channel matches the setting in the record for the selected channel and signal an error otherwise.

 
        else if(chanCount==1){ 
            chanRecord = [chanLookup objectAtIndex:0]; 
 
            if([[chanRecord objectForKey:@”open”] 
                    isEqualToString:(open==’Y’ ? @”Y” : @”N”)]==NO){ 
                err = FMT1(@”Channel_is_not_%@.”, 
                            (open==’Y’ ? @”open” : @”private”)); 
                ErrmsgAndExit(err, candidate); 
            } 
 
            channel = [chanRecord objectForKey:@”name”]; 
            chanid = [chanRecord objectForKey:@”id”]; 
        }

We have a new channel if we did not obtain any records from the SELECT query. We build an INSERT query that contains the time stamp, the name, the open flag and the encrypted password. (The salt will be stored in the first two characters of the output from ENCRYPT.)

 
        else{ 
            query = FMT4(@”INSERT_INTO_” 
                          @”channels_” 
                          @”(id,_time,_name,_password,_open)_” 
                          @”VALUES_(’’,_’%ld’,_’%@’,_” 
                          @”ENCRYPT(’%s’),_’%c’)”, 
                          now, candidate, 
                          (Empty(password)==YES ? 
                           ”” : [password cString]), open);

The channel was created if the query succeeded. We can set the channel name to the candidate value. We must ask MySQL for the channel id that it created. (Recall that AUTO_INCREMENT was set.)

 
            if(mysql_query(&mysql, [query cString])){ 
                err = FMT1(@”Couldn’t_create_channel:_%@”, 
                            candidate); 
                ErrmsgAndExit(err, MYSQL_ERR); 
            } 
 
            channel = candidate; 
            chanid = FMT1(@”%ld”, (long)mysql_insert_id(&mysql)); 
        }

At this point we can be certain of having a valid channel id and a channel name. It remains to check the password in the case of a logon to a private channel that wasn’t just created (chanCount==1). We ask MySQL for the id of any channel whose encrypted password matches the result of encrypting the user-supplied password, with the first two characters of the stored password being the salt.

 
        if(open==’N’ && chanCount==1){ 
            query = FMT2(@”SELECT_” 
                          @”id_FROM_channels_” 
                          @”WHERE_id_=_’%@’_AND_” 
                          @”password_=_” 
                          @”ENCRYPT(’%@’,_LEFT(password,_2))”, 
                          chanid, password);

The query must have succeeded and there must be a result set for us to process if we are to proceed. We could have used RowsFromQuery, except that we are not interested in the particular record, but rather in the number of records. There should be a single record.

 
            if(mysql_query(&mysql, [query cString])){ 
                err = FMT1(@”Password_query_failed:_%@”, query); 
                ErrmsgAndExit(err, MYSQL_ERR); 
            } 
            if((result=mysql_store_result(&mysql))==NULL){ 
                err = FMT1(@”No_result_from_password_query:_%@”, 
                            query); 
                ErrmsgAndExit(err, MYSQL_ERR); 
            }

We obtain the number of rows. There will be no rows if the password was wrong and one row otherwise. We send an error (“Permission denied.”) to the client if that was the case. This concludes the processing associated to channel selection.

 
            int rescount = (int)mysql_num_rows(result); 
            if(rescount!=1){ 
                aux = FMT6(@”User_was_%@,_” 
                            @”channel_%@,_id_%@,” 
                            @”password_%@;_” 
                            @”combo_gave_%d_entries.<P> 
                            @”query_was:_%@”, 
                            login, channel, chanid, 
                            password, rescount, query); 
                ErrmsgAndExit(@”Permission_denied.”, aux); 
            } 
            mysql_free_result(result); 
        }

The following else clause deals with the case where we had a valid channel id in the session dictionary. We need the channel’s name rather than its id for the display frame, which should not only display messages, but also indicate what user is viewing what channel. Hence we build a query that will yield the channel’s name and execute it.

 
    else{ 
        query = FMT1(@”SELECT_name_FROM_channels_WHERE_id=’%@’”, 
                      chanid); 
        chanLookup = RowsFromQuery(mysql, query); 
        chanCount = [chanLookup count];

There is something wrong if we obtained no entry or more than one entry. We set the channel name if everything was okay.

 
        if(chanCount!=1){ 
            err = FMT1(@”Expected_one_channel_for_ID_%@.”, 
                        chanid); 
            aux = FMT1(@”Got_%d.”, chanCount); 
            ErrmsgAndExit(err, aux); 
        } 
 
        channel = [[chanLookup objectAtIndex:0] 
                       objectForKey:@”name”]; 
    }

If we got this far in the program, then the session must already be fully initialized: we have a user name, a refresh setting, an “open” flag and a channel id and a channel name. We should output either the main chat screen (frame set), the upper frame (message display) or the lower frame (message entry form). What exactly we should do is determined by the parameter “cmd.” It is either “display” or “enter” or nil. Anything else is an error. We start by responding to the display command. We select the message text and the author’s name from the database. We again let MySQL do the sorting.

 
    if(cmd!=nil){ 
        if([cmd isEqualToString:@”display”]==YES){ 
            query = FMT2(@”SELECT_msg,_login_” 
                          @”FROM_messages_” 
                          @”WHERE_chanid=’%@’_” 
                          @”ORDER_BY_time_” 
                          @”DESC_LIMIT_%d”, 
                          chanid, DISPLAY_MAX); 
            NSMutableArray *msgs = RowsFromQuery(mysql, query);

We emit the refresh header right away. This is safe to do because we know that there cannot be any errors after this point. We initialize the parameters for OutputPageAndExit. The first line of the body displays the current user and the channel name. We set the “onLoad” event property to a Javascript timeout, which is in milliseconds. This is to be on the safe side; theoretically, either the refresh header or the timeout alone should suffice.

 
            printf(”Refresh:_%d\r\n”, refresh); 
            title = @”Chat_message”; 
            frames = @””; 
 
            body = FMT2(@<FONT_SIZE=24>\n” 
                         @<B>User:_%@_&nbsp;_Channel:_%@</B>\n” 
                         @</FONT>\n<P>\n”, login, channel); 
            bodyargs = FMT1(@”_onLoad=\”javascript:” 
                             @”window.setTimeout(” 
                             @”’location.reload()’,_%ld)\””, 
                             (long)(1000)*(long)refresh);

The messages go into a HTML table. We build the table line by line, storing lines in the array msgOutput. We obtain an iterator for the set of messages and loop over it.

 
            NSMutableArray *msgOutput = 
                [NSMutableArray 
                     arrayWithObject: 
                         @<TABLE_WIDTH=100%_BORDER>]; 
            NSEnumerator *msgEnum = [msgs objectEnumerator]; 
            NSMutableDictionary *item; 
            while((item = [msgEnum nextObject])!=nil){

The content of the message as retrieved from MySQL consists of a string of hexdecimal values, which we decode into HTML entities. There is one row with two columns for each message. The first column holds the name of the author and the second the message itself. We store the string for the current row in the output array.

 
                NSString *encoded = [item objectForKey:@”msg”], 
                     *entities = DecodeMsgIntoEntities(encoded); 
                NSString *row = 
                     FMT2(@<TR><TD_WIDTH=20%> 
                          @”%@</TD><TD_WIDTH=80%>%@</TR>, 
                          [item objectForKey:@”login”], 
                          entities); 
                [msgOutput addObject:row]; 
            }

We close the table once we have iterated over all messages and produce a single string by joining the output lines, separating them from each other with a newline character. We are now done processing the “display” command and may output the page.

 
            [msgOutput addObject:@</TABLE>]; 
 
            NSString *allmsgs = 
                [msgOutput componentsJoinedByString:@\n”]; 
            body = [body stringByAppendingString:allmsgs]; 
 
            OutputPageAndExit(mysql, session, 
                               title, frames, 
                               body, bodyargs); 
        }

The second command that we must implement is the command “enter,” which records a message and update’s the channel’s time stamp. There are two steps. If there is a message among the CGI parameters, then we must record it. We must also output a message submit form thereafter, regardless of whether there was a message or not.

In case of a message we encode it as a string of hexadecimal values first. Then we build the query, which records the time, the author, the channel id and the text of the message.

 
        else if([cmd isEqualToString:@”enter”]==YES){ 
            NSString *msg = [params objectForKey:@”msg”]; 
            if(!Empty(msg)){ 
                NSString *encoded = EncodeMsg(msg); 
                query = FMT4(@”INSERT_” 
                              @”INTO_messages_” 
                              @”(time,_login,_chanid,_msg)_” 
                              @”VALUES” 
                              @”(’%ld’,_’%@’,_’%@’,_’%@’)”, 
                              now, login, chanid, encoded);

The query must succeed or we output an error message.

 
                if(mysql_query(&mysql, [query cString])){ 
                     err = FMT1(@”Couldn’t_insert_message:_%@”, 
                                DecodeMsgIntoEntities(encoded)); 
                     ErrmsgAndExit(err, MYSQL_ERR); 
                }

The process of recording a message includes an update of the channel’s time stamp to indicate that there has been activity on the channel. Recall that this time stamps determines how long the channel will stay in the database and where on the channel menu it will be displayed if it is an open channel. The query is simple: enter the time stamp in the record with the appropriate channel id.

 
                query = FMT2(@”UPDATE_channels_” 
                              @”SET_time=’%ld’” 
                              @”WHERE_id=’%@’”, now, chanid);

This query, too, must succeed, or we signal an error.

 
                if(mysql_query(&mysql, [query cString])){ 
                     NSString *err = 
                         FMT2(@”Couldn’t_update_” 
                              @”channel:_%@,_query_%@”, 
                              channel, query); 
                     ErrmsgAndExit(err, MYSQL_ERR); 
                } 
            }

Step two of the response to the command “enter” is to output the message entry form. This is mostly static text and includes the button that takes the user back to the login screen. This button should not be missing from the main screen. It remains to point out the Javascript focus call, which makes it possible for a user to send a sequence of messages without having to click in the message field every time. There are three fields: the message, a hidden field which sets the command to “enter” and a submit button.

 
            title = @”Chat_message”; 
            frames = @””; 
            body = FMT1(@<FONT_SIZE=24> 
                         @<B>Message</B></FONT>\n” 
                         @<FORM_NAME=enter_METHOD=POST_” 
                         @”ACTION=webchat.sh>\n” 
                         @<INPUT_TYPE=TEXT_NAME=msg_SIZE=80_” 
                         @”MAXLENGTH=%d>\n” 
                         @<INPUT_TYPE=HIDDEN_NAME=cmd_” 
                         @”VALUE=enter>\n” 
                         @<INPUT_TYPE=SUBMIT_VALUE=Submit>\n” 
                         @</FORM>\n”, LEN_MSG); 
            body = [body stringByAppendingString:BackToLogin()]; 
            bodyargs = @”_onLoad=” 
                @\”javascript:document.enter.msg.focus();\””;

We must reset the cookie’s expiry date because an enter signals that there has been activity for the current session. We invoke SetCookie for this purpose, and conclude by outputting the page.

 
            SetCookie(sessid); 
            OutputPageAndExit(mysql, session, 
                               title, frames, body, bodyargs); 
        }

We have now responded to all possible commands. Any other command is an error.

 
        else{ 
            err = @”Unknown_command.”; 
            aux = [NSString stringWithFormat:@”Got_%@.”, 
                             cmd]; 
            ErrmsgAndExit(err, aux); 
        } 
    }

The final section of the program is for the case when all session variables were set, but no command was received. This is the default case and it indicates that we should display the main screen with its two frames. The frame set allocates 65% of the browser’s height to the upper frame, where messages are displayed, and 35% to the lower frame, where messages are entered. The request method by which commands are transmitted is GET. We can output the page once the arguments for OutputPageAndExit have been initialized. The exit statement at the end of the program is not reached.

 
    title = FMT2(@”Chat_user:_%@_Channel:_%@”, 
                 login, channel); 
    frames = @<FRAMESET_ROWS=\”65%,_35%\>\n” 
        @<FRAME_SRC=webchat.sh?cmd=display_” 
        @”SCROLLING=no_NAME=display>\n” 
        @<FRAME_SRC=webchat.sh?cmd=enter_” 
        @”SCROLLING=no_NAME=enter>\n” 
        @</FRAMESET>\n” 
        @<NOFRAMES>\n” 
        @”This_software_needs_a_browser_” 
        @”that_can_display_frames.\n” 
        @</NOFRAMES>\n”; 
    body = @””; 
    bodyargs = @””; 
    OutputPageAndExit(mysql, session, 
                       title, frames, 
                       body, bodyargs); 
 
    // not reached 
    [pool release]; 
    exit(0); 
}

This concludes the webchat recipe. It is based on a PHP webchat that I wrote some years ago, which in turn was inspired by a script I received from Igor Gilitschenski in July 2000.