diff options
-rw-r--r-- | COPYING | 20 | ||||
-rw-r--r-- | Makefile.am | 10 | ||||
-rw-r--r-- | README | 41 | ||||
-rw-r--r-- | bwchat-cgi.1 | 30 | ||||
-rw-r--r-- | bwchat-server.1 | 29 | ||||
-rw-r--r-- | bwchat.h | 27 | ||||
-rw-r--r-- | bwchat.js | 96 | ||||
-rw-r--r-- | bwchat_cgi.c | 630 | ||||
-rw-r--r-- | bwchat_server.c | 292 | ||||
-rw-r--r-- | configure.ac | 30 |
10 files changed, 1205 insertions, 0 deletions
@@ -0,0 +1,20 @@ +Copyright (c) 2024 defanor <defanor@thunix.net> + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile.am b/Makefile.am new file mode 100644 index 0000000..19e3474 --- /dev/null +++ b/Makefile.am @@ -0,0 +1,10 @@ +# Installing bwchat.h for use by other programs, extending the +# chat. Otherwise it would go into _SOURCES. +include_HEADERS = bwchat.h +man1_MANS = bwchat-cgi.1 bwchat-server.1 +dist_man_MANS = bwchat-cgi.1 bwchat-server.1 +dist_data_DATA = bwchat.js +AM_CFLAGS = -std=c89 -Wall -Wextra -pedantic +bin_PROGRAMS = bwchat-server bwchat-cgi +bwchat_server_SOURCES = bwchat_server.c +bwchat_cgi_SOURCES = bwchat_cgi.c @@ -0,0 +1,41 @@ +bwchat, a basic web chat + + +A simple chat featuring textual messages, file upload, audio streams. + +The aim is to implement that functionality using the simplest +necessary technologies and relatively little code, so that (hopefully) +it works sufficiently well, is readily usable by casual computer +users, with commonly installed web browsers, and is easy to deploy. + +Written in C, optionally uses libfcgi. Does not include features +beyond the essentials required for communication, assumes cooperating +participants. + +The textual chat, file upload, and audio stream consumption work +without JavaScript, making the basic chat functionality available from +simpler web browsers (e.g., lynx, w3m) and audio streams available via +audio players, while audio stream upload depends on JavaScript. When +JS is available, AJAX is used for message sending and retrieval. The +supported audio streams are Ogg with the Opus codec. + +The chat consists of bwchat-server, a daemon that keeps the state and +organizes clients, and bwchat-cgi, a (Fast)CGI program handling +individual HTTP requests. Additional clients can be implemented with +the bwchat.h header, including those for interfaces other than CGI. + + +Building, setup, and running instructions: retrieve the sources +(possibly by cloning the repository), ensure that nginx, spawn-fcgi, +libfcgi (-dev), autotools, and a C compiler (e.g., gcc or clang) are +installed, run autoreconf -i, create missing files, use the generated +configure script, make. Run bwchat-server on its own, and bwchat-cgi +with spawn-fcgi, with a suitable fastcgi nginx configuration. Place +chat.js into a suitable directory served by nginx. Generally it is a +good idea to run such services with reduced privileges, possibly +chrooted or otherwise restricted. SCRIPT_NAME's basename should be +"chat" to render the main page. + +Alternatively, use a different web server, different FastCGI runner +(or plain CGI), build the programs manually, skip chat.js, tweak the +runtime options (see --help or man pages). diff --git a/bwchat-cgi.1 b/bwchat-cgi.1 new file mode 100644 index 0000000..721ef2f --- /dev/null +++ b/bwchat-cgi.1 @@ -0,0 +1,30 @@ +.TH bwchat-cgi 1 "2024-04-22" "bwchat 0.0.0" + +.SH NAME +bwchat\-cgi \- the bwchat's (Fast)CGI program + +.SH SYNOPSIS +.B bwchat-cgi +.RI [ options ] + +.SH DESCRIPTION +Handles client requests, interacts with +.BR bwchat\-server (1). + +.SH OPTIONS +.TP +.BI \-j\ URL \fR,\ \fB\-\-js\-url= URL +JavaScript (bwchat.js) URL to reference from HTML +.TP +.BI \-l\ \fR,\ \fB\-\-log\-stderr +Write logs into stderr, in addition to syslog +.TP +.BI \-s\ PATH \fR,\ \fB\-\-socket\-path= PATH +The bwchat-server's Unix domain socket path +.TP +.BI \-u\ URL \fR,\ \fB\-\-upload\-dir\-url= URL +The file upload directory's URL to use in hyperlinks + +.SH SEE ALSO +.BR bwchat\-server (1), +.BR spawn\-fcgi (1) diff --git a/bwchat-server.1 b/bwchat-server.1 new file mode 100644 index 0000000..34ebdfb --- /dev/null +++ b/bwchat-server.1 @@ -0,0 +1,29 @@ +.TH bwchat\-server 1 "2024-04-22" "bwchat 0.0.0" + +.SH NAME +bwchat\-server \- the bwchat's chat server + +.SH SYNOPSIS +.B bwchat-server +.RI [ options ] + +.SH DESCRIPTION +Keeps the chat state, accessed by +.BR bwchat\-cgi (1) +processes. + +.SH OPTIONS +.TP +.BI \-l\ \fR,\ \fB\-\-log\-stderr +Write logs into stderr, in addition to syslog +.TP +.BI \-s\ PATH \fR,\ \fB\-\-socket\-path= PATH +The Unix domain socket path to listen on + +.SH SIGNALS +.TP +SIGTERM, SIGINT, SIGQUIT +Exit gracefully. + +.SH SEE ALSO +.BR bwchat\-cgi (1) diff --git a/bwchat.h b/bwchat.h new file mode 100644 index 0000000..c0a3d2f --- /dev/null +++ b/bwchat.h @@ -0,0 +1,27 @@ +#include <stddef.h> +#include <time.h> + +#define BWC_MESSAGE_LENGTH (32 * 1024) +#define BWC_NICK_LENGTH 32 + +enum bwchat_command { + BWC_CMD_ADD_MESSAGE, + BWC_CMD_ALL_MESSAGES, + BWC_CMD_NEW_MESSAGES, + BWC_CMD_AUDIO_STREAM +}; + +enum bwchat_message_type { + BWC_MESSAGE_NONE, + BWC_MESSAGE_TEXT, + BWC_MESSAGE_UPLOAD, + BWC_MESSAGE_AUDIO +}; + +struct bwchat_message { + time_t timestamp; + char nick[BWC_NICK_LENGTH]; + enum bwchat_message_type type; + char data[BWC_MESSAGE_LENGTH]; + size_t data_len; +}; diff --git a/bwchat.js b/bwchat.js new file mode 100644 index 0000000..242b0da --- /dev/null +++ b/bwchat.js @@ -0,0 +1,96 @@ +/** + @file bwchat.js + @brief A client-side script for use with bwchat + @author defanor <defanor@thunix.net> + @date 2024 + @copyright MIT license +*/ + +var mediaRecorder = null; +var streamButton = null; + +function handleDataAvailable(event) { + if (event.data.size > 0) { + const formData = new FormData(); + formData.append("stream", ""); + formData.append("nick", document.getElementsByName("nick")[0].value); + formData.append("message", event.data); + // TODO: would be better to run a timer once the request is + // processed, rather than to issue them regularly. + fetch("chat", { method: "POST", body: formData }) + .catch((err) => { + console.error(err); + mediaRecorder.stop(); + mediaRecorder = null; + streamButton.value = "Start streaming"; + }); + } +} + +function stream() { + if (streamButton.value == "Start streaming") { + navigator.mediaDevices + .getUserMedia({audio: true, video: false}) + .then((mediaStream) => { + const options = { mimeType: "audio/ogg; codec=opus" }; + mediaRecorder = new MediaRecorder(mediaStream, options); + mediaRecorder.ondataavailable = handleDataAvailable; + mediaRecorder.start(500); + streamButton.value = "Stop streaming"; + }) + .catch((err) => console.error(err)); + } else { + mediaRecorder.stop(); + mediaRecorder = null; + streamButton.value = "Start streaming"; + } +} + +addEventListener("DOMContentLoaded", (event) => { + // Set the streaming button + streamButton = document.createElement("input"); + streamButton.type = "button"; + streamButton.value = "Start streaming"; + streamButton.onclick = stream; + document.body.appendChild(streamButton); + + // Setup AJAX-based message submission + var messages = document.getElementById("messages"); + var chatInputForm = document.getElementById("chatInputForm"); + chatInputForm.addEventListener("submit", function (e) { + var nick = document.getElementsByName("nick")[0]; + var message = document.getElementsByName("message")[0]; + if (nick.value.length > 0 && message.value.length > 0) { + const formData = new FormData(); + formData.append("nick", nick.value); + formData.append("message", message.value); + fetch("chat", { method: "POST", body: formData }) + .catch((err) => console.error(err)); + message.value = ''; + e.preventDefault(); + return false; + } + }); + + // Setup AJAX-based message retrieval + fetch("messages").then((response) => { + const reader = response.body.getReader(); + reader.read().then(function pump({done, value}) { + if (done) { + return; + } + var str = new TextDecoder().decode(value); + if (str.trim().length > 0) { + messages.innerHTML += str; + // if (messages.lastElementChild.lastElementChild + // .tagName == "AUDIO") { + // messages.lastElementChild.lastElementChild.play(); + // } + } + while (messages.childElementCount > 20) { + messages.firstElementChild.remove(); + } + reader.read().then(pump); + }); + }).catch((err) => console.error(err)); +}); diff --git a/bwchat_cgi.c b/bwchat_cgi.c new file mode 100644 index 0000000..6ae0e04 --- /dev/null +++ b/bwchat_cgi.c @@ -0,0 +1,630 @@ +/** + @file bwchat_cgi.c + @brief bwchat CGI + @author defanor <defanor@thunix.net> + @date 2024 + @copyright MIT license +*/ + +#include <stdlib.h> +#include <string.h> +#include <stdio.h> +#include <libgen.h> +#include <fcntl.h> +#include <time.h> +#include <unistd.h> +#include <syslog.h> +#include <sys/socket.h> +#include <sys/un.h> +#include <errno.h> +#include <sys/select.h> +#include <argp.h> + +#include "bwchat.h" + +#ifdef HAVE_CONFIG_H +#include "config.h" +#endif +#ifdef HAVE_FCGI +#include "fcgi_stdio.h" +#endif + +#define BOUNDARY_LENGTH 128 +#define FIELD_NAME_LENGTH 128 +#define FILENAME_LENGTH 128 + +enum form_data_parsing_state { + FORM_PARSE_START, + FORM_PARSE_DONE, + FORM_PARSE_FAIL, + FORM_PARSE_HEADER, + FORM_PARSE_DATA +}; + +enum param_read_state { + PARAM_READ_SEARCH, + PARAM_READ_FOUND_COLON, + PARAM_READ_FOUND_PARAM, + PARAM_READ_QUOTED, + PARAM_READ_UNQUOTED, + PARAM_READ_SKIP_QUOTED +}; + +/* Global state */ +int sock = -1; + +/* Settings */ +const char *upload_dir_url = "upload/"; +const char *js_url = "bwchat.js"; +const char *sock_path = "bwchat-socket"; +int log_stderr = 0; + +char *html_escape (char *dst, const char *src, size_t sz) { + size_t i, j; + for (i = 0, j = 0; (i < (sz - 1)) && (j < (sz - 1)); i++, j++) { + if (src[i] == '<') { + strncpy(dst + j, "<", sz - j); + j += 3; + } else if (src[i] == '>') { + strncpy(dst + j, ">", sz - j); + j += 3; + } else if (src[i] == '"') { + strncpy(dst + j, """, sz - j); + j += 5; + } else if (src[i] == '&') { + strncpy(dst + j, "&", sz - j); + j += 4; + } else { + dst[j] = src[i]; + } + } + dst[j] = '\0'; + return dst; +} + +/* + https://www.rfc-editor.org/rfc/rfc822 -- Internet text messages + https://www.rfc-editor.org/rfc/rfc2183 -- Content-Disposition + https://www.rfc-editor.org/rfc/rfc7578 -- multipart/form-data +*/ +char *read_param (const char *line, const char *name, char *dst, size_t sz) { + size_t i, j; + size_t line_len = strlen(line); + size_t name_len = strlen(name); + enum param_read_state s = PARAM_READ_SEARCH; + dst[0] = '\0'; + for (i = 0, j = 0; i < line_len && j < sz; i++) { + if (s == PARAM_READ_SEARCH) { + if (line[i] == ';') { + s = PARAM_READ_FOUND_COLON; + } else if (line[i] == '"') { + s = PARAM_READ_SKIP_QUOTED; + } + } else if (s == PARAM_READ_FOUND_COLON && line[i] != ' ') { + if (i + name_len + 1 < line_len && + strncmp(line + i, name, name_len) == 0 && + line[i + name_len] == '=') { + s = PARAM_READ_FOUND_PARAM; + i += name_len; + } + } else if (s == PARAM_READ_FOUND_PARAM) { + if (line[i] == '"') { + s = PARAM_READ_QUOTED; + } else { + s = PARAM_READ_UNQUOTED; + dst[j] = line[i]; + j++; + } + } else if (s == PARAM_READ_SKIP_QUOTED) { + if (line[i] == '\\') { + i++; + } else if (line[i] == '"') { + s = PARAM_READ_SEARCH; + } + } else if (s == PARAM_READ_UNQUOTED) { + if (line[i] == '\r' || line[i] == '\n' || + line[i] == ';' || line[i] == ' ') { + dst[j] = '\0'; + return dst; + } + dst[j] = line[i]; + j++; + } else if (s == PARAM_READ_QUOTED) { + if (line[i] == '\\') { + i++; + } else if (line[i] == '"') { + dst[j] = '\0'; + return dst; + } + dst[j] = line[i]; + j++; + } + } + if (j > 0) { + dst[j] = '\0'; + return dst; + } + return NULL; +} + + +/* Reads data until a given substring is found. Aims successive calls, + to read data in chunks. The zero byte is inserted after the data, + making it suitable for textual data reading. */ +int read_till (const char *end, + char *out, + size_t out_buf_len, + size_t *out_data_len, + size_t *matched) +{ + size_t end_len = strlen(end); + + /* Check for a previous iteration's leftover, move it. */ + if (*out_data_len > 0) { + memmove(out, out + *out_data_len, *matched); + *out_data_len = 0; + } + + while (*out_data_len + *matched < out_buf_len) { + int c = getchar(); + if (c == -1) { + return -1; + } + out[*out_data_len + *matched] = c; + if (out[*out_data_len + *matched] == end[*matched]) { + /* Extending the match */ + *matched = *matched + 1; + } else { + /* Not matching: find the new matching prefix */ + *out_data_len = *out_data_len + 1; + while (*matched > 0 && + strncmp(out + *out_data_len, end, *matched) != 0) { + *matched = *matched - 1; + *out_data_len = *out_data_len + 1; + } + } + + /* Found the match */ + if (end_len == *matched) { + out[*out_data_len] = '\0'; + return 0; + } + } + /* Not found yet: the caller should consume the read bytes and keep + iterating. */ + return 1; +} + + +int sock_conn() { + struct sockaddr_un addr; + socklen_t addr_size; + sock = socket(AF_UNIX, SOCK_SEQPACKET, 0); + if (sock < 0) { + return -1; + } + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, sock_path, sizeof(addr.sun_path) - 1); + addr_size = sizeof(struct sockaddr_un); + if (connect(sock, (struct sockaddr *) &addr, addr_size) < 0) { + return -1; + } + return sock; +} + +int print_message (struct bwchat_message *msg) { + char nick[BWC_NICK_LENGTH]; + char message[BWC_MESSAGE_LENGTH]; + struct tm *btime; + + if (msg->type == BWC_MESSAGE_NONE) { + return 0; + } + btime = localtime(&(msg->timestamp)); + strftime(message, BWC_MESSAGE_LENGTH, "%H:%M", btime); + html_escape(nick, msg->nick, BWC_NICK_LENGTH); + if (printf(" <div>%s <b>%s</b>: ", message, nick) < 0) { + return -1; + } + if (msg->type == BWC_MESSAGE_TEXT) { + html_escape(message, msg->data, BWC_MESSAGE_LENGTH); + if (printf("%s", message) < 0) { + return -1; + } + } else if (msg->type == BWC_MESSAGE_UPLOAD) { + html_escape(message, msg->data, BWC_MESSAGE_LENGTH); + if (printf("<a href=\"%s%s\">%s</a>", upload_dir_url, + message, message) < 0) { + return -1; + } + } else if (msg->type == BWC_MESSAGE_AUDIO) { + if (printf + ("<audio controls=\"\" preload=\"none\" src=\"stream?%s\"></audio>", + nick) < 0) { + return -1; + } + } + if (puts("</div>") < 0) { + return -1; + } + return 0; +} + +int print_messages () { + struct bwchat_message msg; + ssize_t len; + char c = BWC_CMD_ALL_MESSAGES; + if (printf(" <div id=\"messages\">\n") < 0) { + return -1; + } + write(sock, &c, 1); + while (1) { + len = read(sock, &msg, sizeof(msg)); + if (len == 0) { + break; + } + if (len < (ssize_t)sizeof(msg)) { + return -1; + } + if (print_message(&msg) != 0) { + return -1; + } + } + if (printf(" </div>\n") < 0) { + return -1; + } + return 0; +} + + +int serve_messages () { + fd_set rset; + struct timeval timeout; + struct bwchat_message msg; + char c = BWC_CMD_NEW_MESSAGES; + int ret; + if (printf("Content-type: text/html\r\n" + "Cache-Control: no-cache\r\n" + "X-Accel-Buffering: no\r\n" + "\r\n") < 0) { + return -1; + } + write(sock, &c, 1); + + while (1) { + timeout.tv_sec = 10; + timeout.tv_usec = 0; + FD_ZERO(&rset); + FD_SET(sock, &rset); + ret = select(sock + 1, &rset, NULL, NULL, &timeout); + if (ret == 0) { + /* Timeout, send a ping. Primarily to see if the client is still + there, though it would also let the client know that the + connection is live, and possibly help to avoid gateway + timeouts. */ + if (puts("") < 0) { + break; + } + } else if (ret == 1) { + /* Input available */ + if (read(sock, &msg, sizeof(msg)) != sizeof(msg)) { + syslog(LOG_WARNING, "serve_messages: bwchat-server is gone"); + return 0; + } + if (print_message(&msg) != 0) { + break; + } + } else if (ret == -1) { + /* Error */ + syslog(LOG_ERR, "select() error in serve_messages"); + break; + } + if (fflush(stdout) < 0) { + break; + } + } + syslog(LOG_DEBUG, "a message listener is gone"); + return 0; +} + +int serve_stream () { + fd_set rset; + struct timeval timeout; + char *query_string = getenv("QUERY_STRING"); + char buf[BWC_MESSAGE_LENGTH]; + size_t len; + int ret; + buf[0] = BWC_CMD_AUDIO_STREAM; + strncpy(buf + 1, query_string, BWC_NICK_LENGTH); + write(sock, buf, BWC_NICK_LENGTH + 1); + + /* Send HTTP headers */ + printf("Content-type: audio/ogg\r\n" + "Cache-Control: no-cache\r\n" + "X-Accel-Buffering: no\r\n" + "\r\n"); + + /* Wait for new stream chunks, pass them to the client */ + while (1) { + timeout.tv_sec = 10; + timeout.tv_usec = 0; + FD_ZERO(&rset); + FD_SET(sock, &rset); + ret = select(sock + 1, &rset, NULL, NULL, &timeout); + if (ret != 1) { + /* Timeout or error: break. */ + break; + } + len = read(sock, &buf, sizeof(buf)); + if (len <= 0) { + syslog(LOG_WARNING, "serve_stream: bwchat-server is gone"); + return 0; + } + if (fwrite(buf, 1, len, stdout) < len) { + break; + } + if (fflush(stdout) < 0) { + break; + } + } + syslog(LOG_DEBUG, "an audio stream listener is gone"); + return 0; +} + +int handle_chat () { + size_t message_len = 0, matched = 0, len = 0; + enum form_data_parsing_state ps = FORM_PARSE_START; + char + *request_method = getenv("REQUEST_METHOD"), + *content_type = getenv("CONTENT_TYPE"), + message[BWC_MESSAGE_LENGTH + BOUNDARY_LENGTH + 4] = "\0", + nick[BWC_NICK_LENGTH + BOUNDARY_LENGTH + 4] = "\0", + filename[FILENAME_LENGTH] = "\0", + boundary[BOUNDARY_LENGTH + 4], + field_name[FIELD_NAME_LENGTH], + buf[4096]; + int stream = 0; + + if (strcmp(request_method, "POST") == 0) { + if (content_type != NULL && + strncmp(content_type, "multipart/form-data;", 20) == 0) { + /* Parse form data: nick, message, file, stream */ + strcpy(boundary, "\r\n--"); + read_param(content_type, "boundary", + boundary + 4, BOUNDARY_LENGTH - 2); + if (read_till(boundary + 2, buf, 4096, &len, &matched) != 0) { + syslog(LOG_ERR, "No initial boundary found"); + ps = FORM_PARSE_FAIL; + } + while (ps != FORM_PARSE_FAIL && ps != FORM_PARSE_DONE) { + len = 0; + matched = 0; + if (ps == FORM_PARSE_START) { + if (read_till("\r\n", buf, 4096, &len, &matched) == 0) { + if (len == 0) { + ps = FORM_PARSE_HEADER; + } else if (len == 2 && strcmp(buf, "--") == 0) { + ps = FORM_PARSE_DONE; + } + } else { + syslog(LOG_ERR, "Failed to start parsing"); + ps = FORM_PARSE_FAIL; + } + } else if (ps == FORM_PARSE_HEADER) { + if (read_till("\r\n", buf, 4096, &len, &matched) == 0) { + if (strncmp(buf, "Content-Disposition: form-data;", 31) == 0) { + read_param(buf, "name", field_name, FIELD_NAME_LENGTH); + read_param(buf, "filename", filename, FILENAME_LENGTH); + } else if (len == 0) { + ps = FORM_PARSE_DATA; + } + } else { + syslog(LOG_ERR, "Failed to parse a header"); + ps = FORM_PARSE_FAIL; + } + } else if (ps == FORM_PARSE_DATA) { + if (strcmp(field_name, "nick") == 0) { + if (read_till(boundary, nick, + BWC_NICK_LENGTH + BOUNDARY_LENGTH + 4, + &len, &matched) == 0) { + ps = FORM_PARSE_START; + } else { + syslog(LOG_ERR, "No boundary after nick"); + ps = FORM_PARSE_FAIL; + } + } else if (strcmp(field_name, "message") == 0) { + if (read_till(boundary, message, + BWC_MESSAGE_LENGTH + BOUNDARY_LENGTH + 4, + &message_len, &matched) == 0) { + ps = FORM_PARSE_START; + } else { + syslog(LOG_ERR, "No boundary after message"); + ps = FORM_PARSE_FAIL; + } + } else if (strcmp(field_name, "file") == 0 && + filename[0] != '\0') { + FILE *f = fopen(basename(filename), "w"); + if (f == NULL) { + syslog(LOG_ERR, "Failed to open a file: %s", strerror(errno)); + } else { + int r; + do { + r = read_till(boundary, buf, 4096, &len, &matched); + if (r >= 0) { + if (fwrite(buf, 1, len, f) < len) { + syslog(LOG_ERR, "Failed to write into a file: %s", + strerror(errno)); + break; + } + } else { + syslog(LOG_ERR, "No boundary after file contents"); + } + } while (r > 0); + if (fclose(f) != 0) { + syslog(LOG_ERR, "Failed to close a file: %s", strerror(errno)); + } + } + ps = FORM_PARSE_START; + } else if (strcmp(field_name, "stream") == 0) { + if (read_till(boundary, buf, 4096, &len, &matched) == 0) { + stream = 1; + ps = FORM_PARSE_START; + } else { + syslog(LOG_ERR, "No boundary after stream"); + ps = FORM_PARSE_FAIL; + } + } else { + /* Skip unknown fields */ + while (read_till(boundary, buf, 4096, &len, &matched) > 0); + ps = FORM_PARSE_START; + } + } + } + + /* Process the parsed form data */ + if (nick[0] != '\0') { + char buf[sizeof(struct bwchat_message) + 1]; + struct bwchat_message *msg = (struct bwchat_message *)(buf + 1); + time(&(msg->timestamp)); + strncpy(msg->nick, nick, BWC_NICK_LENGTH); + msg->nick[sizeof(msg->nick) - 1] = '\0'; + buf[0] = BWC_CMD_ADD_MESSAGE; + if (stream && message_len > 0) { + /* A chunk of stream */ + msg->type = BWC_MESSAGE_AUDIO; + msg->data_len = message_len; + memcpy(msg->data, message, BWC_MESSAGE_LENGTH); + len = write(sock, buf, sizeof(buf)); + } else if (message[0] != '\0' || filename[0] != '\0') { + /* A new message: either textual or file upload. */ + if (message[0] != '\0') { + /* New text message */ + msg->type = BWC_MESSAGE_TEXT; + strncpy(msg->data, message, BWC_MESSAGE_LENGTH); + msg->data[sizeof(msg->data) - 1] = '\0'; + } else if (filename[0] != '\0') { + /* New file upload message */ + msg->type = BWC_MESSAGE_UPLOAD; + strncpy(msg->data, filename, FILENAME_LENGTH); + } + msg->data_len = strlen(msg->data); + if (write(sock, buf, sizeof(buf)) != sizeof(buf)) { + syslog(LOG_ERR, "Failed to submit a new message: %s", + strerror(errno)); + } + /* Reopen the socket, since we will request messages after. */ + if (close(sock) < 0) { + syslog(LOG_ERR, "Socket closing error: %s", strerror(errno)); + } + if (sock_conn() < 0) { + syslog(LOG_ERR, "Failed to reconnect to the chat server: %s", + strerror(errno)); + return -1; + } + } + } + } + } + + /* Send a response to the client */ + if (stream) { + printf("Content-type: text/html\r\n" + "\r\n"); + } else { + printf + ("Content-type: text/html\r\n" + "\r\n" + "<!DOCTYPE html>\n" + "<html>\n" + " <head>\n" + " <title>Chat</title>\n" + " <script src=\"%s\"></script>\n" + " </head>\n" + " <body>\n", + js_url); + print_messages(); + printf + (" <form id=\"chatInputForm\" method=\"post\"" + " enctype=\"multipart/form-data\" >\n" + " <input type=\"text\" name=\"nick\" value=\"%s\" />\n" + " <input type=\"text\" name=\"message\" autofocus=\"\"" + " size=\"60\" />\n" + " <input type=\"file\" name=\"file\" />\n" + " <input type=\"submit\" />\n" + " </form>\n" + " </body>\n" + "</html>\n", + (nick[0] != '\0') ? nick : "Anonymous"); + } + return 0; +} + +static struct argp_option options[] = { + {"js-url", 'j', "URL", 0, + "JavaScript (bwchat.js) URL to reference from HTML", 0 }, + {"log-stderr", 'l', 0, 0, + "Write logs into stderr, in addition to syslog", 0 }, + {"socket-path", 's', "PATH", 0, + "The bwchat-server's Unix domain socket path", 0 }, + {"upload-dir-url", 'u', "URL", 0, + "The URL to use in hyperlinks", 0 }, + { 0 } +}; +static error_t parse_opt (int key, char *arg, struct argp_state *state) { + (void)state; + switch (key) { + case 'u': + upload_dir_url = arg; + break; + case 'j': + js_url = arg; + break; + case 's': + sock_path = arg; + break; + case 'l': + log_stderr = LOG_PERROR; + break; + default: + return ARGP_ERR_UNKNOWN; + } + return 0; +} +static struct argp argp = + { options, parse_opt, 0, "A basic web chat, the CGI program", 0, 0, 0 }; + +int main (int argc, char **argv) { + argp_parse(&argp, argc, argv, 0, 0, 0); + openlog("bwchat-cgi", LOG_PID | log_stderr, 0); + +#ifdef HAVE_FCGI + while (FCGI_Accept() >= 0) { +#endif + char *script_name, *script_bname; + if (sock_conn() < 0) { + syslog(LOG_DEBUG, + "Failed to connect to the chat server at %s: %s", + sock_path, strerror(errno)); +#ifdef HAVE_FCGI + continue; +#else + return -1; +#endif + } + script_name = getenv("SCRIPT_NAME"); + script_bname = basename(script_name); + if (strcmp(script_bname, "stream") == 0) { + serve_stream(); + } else if (strcmp(script_bname, "messages") == 0) { + serve_messages(); + } else { + handle_chat(); + } + if (close(sock) < 0) { + syslog(LOG_ERR, "Socket closing error: %s", strerror(errno)); + } +#ifdef HAVE_FCGI + } +#endif + return 0; +} diff --git a/bwchat_server.c b/bwchat_server.c new file mode 100644 index 0000000..bd5fa17 --- /dev/null +++ b/bwchat_server.c @@ -0,0 +1,292 @@ +/** + @file bwchat_server.c + @brief bwchat server + @author defanor <defanor@thunix.net> + @date 2024 + @copyright MIT license +*/ + +#include <stdio.h> +#include <sys/socket.h> +#include <sys/un.h> +#include <unistd.h> +#include <string.h> +#include <signal.h> +#include <stdlib.h> +#include <syslog.h> +#include <argp.h> + +#include "bwchat.h" + +#define MESSAGE_COUNT 20 +#define LISTENER_COUNT 128 + +struct stream_listener { + int sock; + struct bwchat_message *msg; +}; + +/* Global state */ +int server_sock = -1, client_sock = -1; +int message_listeners[LISTENER_COUNT]; +struct stream_listener stream_listeners[LISTENER_COUNT]; +int log_stderr = 0; + +/* Settings */ +const char *sock_path = "bwchat-socket"; + +static struct argp_option options[] = { + {"log-stderr", 'l', 0, 0, + "Write logs into stderr, in addition to syslog", 0 }, + {"socket-path", 's', "PATH", 0, + "The Unix domain socket path to listen on", 0 }, + { 0 } +}; +static error_t parse_opt (int key, char *arg, struct argp_state *state) { + (void)state; + switch (key) { + case 's': + sock_path = arg; + break; + case 'l': + log_stderr = LOG_PERROR; + break; + default: + return ARGP_ERR_UNKNOWN; + } + return 0; +} +static struct argp argp = + { options, parse_opt, 0, "A basic web chat, the chat server", 0, 0, 0 }; + +void terminate (int signum) { + int i; + syslog(LOG_DEBUG, "Received signal %d, terminating", signum); + for (i = 0; i < LISTENER_COUNT; i++) { + if (message_listeners[i] != -1) { + close(message_listeners[i]); + message_listeners[i] = -1; + } + if (stream_listeners[i].sock != -1) { + close(stream_listeners[i].sock); + stream_listeners[i].sock = -1; + } + } + if (client_sock != -1) { + close(client_sock); + client_sock = -1; + } + close(server_sock); + server_sock = -1; + unlink(sock_path); + exit(0); +} + +/* + https://www.xiph.org/ogg/doc/framing.html -- Ogg + https://www.rfc-editor.org/rfc/rfc7845#section-5 -- Opus +*/ + +int main (int argc, char **argv) { + unsigned int oldest_message = 0; + struct bwchat_message messages[MESSAGE_COUNT]; + struct sockaddr_un server_addr, client_addr; + socklen_t server_addr_size, client_addr_size; + char buf[1 + sizeof(struct bwchat_message)]; + int i; + ssize_t len; + + argp_parse(&argp, argc, argv, 0, 0, 0); + signal(SIGPIPE, SIG_IGN); + signal(SIGTERM, terminate); + signal(SIGINT, terminate); + signal(SIGQUIT, terminate); + openlog("bwchat-server", LOG_PID | log_stderr, 0); + + for (i = 0; i < MESSAGE_COUNT; i++) { + messages[i].type = BWC_MESSAGE_NONE; + } + for (i = 0; i < LISTENER_COUNT; i++) { + message_listeners[i] = -1; + stream_listeners[i].sock = -1; + stream_listeners[i].msg = NULL; + } + + /* Create the socket. */ + server_sock = socket(AF_UNIX, SOCK_SEQPACKET, 0); + if (server_sock < 0) { + syslog(LOG_ERR, "socket() failure: %s", strerror(errno)); + return -1; + } + /* Bind a name to the socket. */ + server_addr.sun_family = AF_UNIX; + strncpy(server_addr.sun_path, sock_path, sizeof(server_addr.sun_path)); + server_addr.sun_path[sizeof(server_addr.sun_path) - 1] = '\0'; + server_addr_size = sizeof(struct sockaddr_un); + if (bind (server_sock, (struct sockaddr *) &server_addr, server_addr_size) < 0) { + syslog(LOG_ERR, "bind() failure: %s", strerror(errno)); + return -1; + } + if (listen(server_sock, 10) < 0) { + syslog(LOG_ERR, "listen() failure: %s", strerror(errno)); + return -1; + } + while (1) { + client_sock = accept(server_sock, + (struct sockaddr *)&client_addr, + &client_addr_size); + if (client_sock < 0) { + syslog(LOG_ERR, "accept() failure: %s", strerror(errno)); + close(server_sock); + client_sock = -1; + return -1; + } + + len = read(client_sock, buf, sizeof(buf)); + if (len <= 0) { + if (len == 0) { + syslog(LOG_WARNING, + "The client disconnected without issuing a command"); + } else { + syslog(LOG_ERR, "read() failure: %s", strerror(errno)); + } + close(client_sock); + client_sock = -1; + continue; + } + + if (buf[0] == BWC_CMD_ADD_MESSAGE && + len == sizeof(struct bwchat_message) + 1) { + struct bwchat_message *src_msg = (struct bwchat_message *)(buf + 1); + struct bwchat_message *upd_msg = NULL; + int new_message = src_msg->type == BWC_MESSAGE_TEXT || + src_msg->type == BWC_MESSAGE_UPLOAD; + if (src_msg->type == BWC_MESSAGE_AUDIO) { + if (src_msg->data[5] & 0x02) { + /* The beginning of a stream: this is going to be a new + message if there is no stream with the same nick; + otherwise updating that one. */ + new_message = 1; + } + for (i = 0; i < MESSAGE_COUNT; i++) { + if (messages[i].type == BWC_MESSAGE_AUDIO && + strcmp(messages[i].nick, src_msg->nick) == 0) { + new_message = 0; + upd_msg = &(messages[i]); + break; + } + } + } + if (new_message) { + /* A new message */ + struct bwchat_message *dst_msg = &(messages[oldest_message]); + if (dst_msg->type == BWC_MESSAGE_AUDIO) { + /* Close sockets for audio listeners. */ + for (i = 0; i < LISTENER_COUNT; i++) { + if (stream_listeners[i].msg == dst_msg) { + close(stream_listeners[i].sock); + stream_listeners[i].sock = -1; + stream_listeners[i].msg = NULL; + } + } + } + oldest_message = (oldest_message + 1) % MESSAGE_COUNT; + memcpy(dst_msg, src_msg, sizeof(struct bwchat_message)); + /* Send the new message to message listeners */ + for (i = 0; i < LISTENER_COUNT; i++) { + if (message_listeners[i] != -1) { + len = write(message_listeners[i], dst_msg, + sizeof(struct bwchat_message)); + if (len < (ssize_t)sizeof(struct bwchat_message)) { + close(message_listeners[i]); + message_listeners[i] = -1; + } + } + } + } else if (upd_msg != NULL) { + if (src_msg->data[5] & 0x02) { + /* A header page, replace the data. */ + memcpy(upd_msg->data, src_msg->data, src_msg->data_len); + upd_msg->data_len = src_msg->data_len; + } + /* Send the new data to stream listeners */ + for (i = 0; i < LISTENER_COUNT; i++) { + if (stream_listeners[i].msg == upd_msg && + stream_listeners[i].sock != -1) { + len = write(stream_listeners[i].sock, + src_msg->data, + src_msg->data_len); + if (len < (ssize_t)(src_msg->data_len)) { + close(stream_listeners[i].sock); + stream_listeners[i].sock = -1; + stream_listeners[i].msg = NULL; + } + } + } + } + close(client_sock); + client_sock = -1; + } else if (buf[0] == BWC_CMD_ALL_MESSAGES) { + for (i = 0; i < MESSAGE_COUNT; i++) { + struct bwchat_message *msg = + &(messages[(oldest_message + i) % MESSAGE_COUNT]); + if (msg->type != BWC_MESSAGE_NONE) { + len = write(client_sock, msg, sizeof(*msg)); + if (len < (ssize_t)sizeof(*msg)) { + break; + } + } + } + close(client_sock); + client_sock = -1; + } else if (buf[0] == BWC_CMD_NEW_MESSAGES) { + for (i = 0; i < LISTENER_COUNT; i++) { + if (message_listeners[i] == -1) { + message_listeners[i] = client_sock; + break; + } + } + if (i == LISTENER_COUNT) { + close(client_sock); + client_sock = -1; + } + } else if (buf[0] == BWC_CMD_AUDIO_STREAM) { + struct bwchat_message *msg = NULL; + buf[BWC_NICK_LENGTH + 1] = '\0'; + for (i = 0; i < MESSAGE_COUNT; i++) { + if (messages[i].type == BWC_MESSAGE_AUDIO && + strcmp(messages[i].nick, buf + 1) == 0) { + msg = &(messages[i]); + break; + } + } + if (msg == NULL) { + close(client_sock); + client_sock = -1; + } else { + for (i = 0; i < LISTENER_COUNT; i++) { + if (stream_listeners[i].sock == -1) { + stream_listeners[i].sock = client_sock; + stream_listeners[i].msg = msg; + + /* Send the header at once. */ + len = write(stream_listeners[i].sock, + msg->data, + msg->data_len); + if (len < (ssize_t)(msg->data_len)) { + close(stream_listeners[i].sock); + stream_listeners[i].sock = -1; + stream_listeners[i].msg = NULL; + } + break; + } + } + if (i == LISTENER_COUNT) { + close(client_sock); + client_sock = -1; + } + } + } + } + return 0; +} diff --git a/configure.ac b/configure.ac new file mode 100644 index 0000000..6179f8c --- /dev/null +++ b/configure.ac @@ -0,0 +1,30 @@ +AC_PREREQ([2.71]) +AC_INIT([bwchat], [0.0.0], [defanor@thunix.net]) +AM_INIT_AUTOMAKE([-Werror -Wall]) +AC_CONFIG_SRCDIR([bwchat_server.c]) +AC_CONFIG_HEADERS([config.h]) +AC_CONFIG_FILES([Makefile]) + +# Checks for programs. +AC_PROG_CC + +# Checks for libraries. +AC_ARG_WITH([fcgi], + [AS_HELP_STRING([--without-fcgi], [disable FastCGI support])]) + +AS_IF([test "x$with_fcgi" != xno], + [AC_SEARCH_LIBS([FCGI_Accept], [fcgi], + [AC_SUBST([LIBFCGI], ["-lfcgi"]) + AC_DEFINE([HAVE_FCGI], [1], [libfcgi is available])])]) + +# Checks for header files. +AC_CHECK_HEADERS([fcntl.h sys/socket.h syslog.h unistd.h]) + +# Checks for typedefs, structures, and compiler characteristics. +AC_TYPE_SIZE_T +AC_TYPE_SSIZE_T + +# Checks for library functions. +AC_CHECK_FUNCS([select socket strerror]) + +AC_OUTPUT |