summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--COPYING20
-rw-r--r--Makefile.am10
-rw-r--r--README41
-rw-r--r--bwchat-cgi.130
-rw-r--r--bwchat-server.129
-rw-r--r--bwchat.h27
-rw-r--r--bwchat.js96
-rw-r--r--bwchat_cgi.c630
-rw-r--r--bwchat_server.c292
-rw-r--r--configure.ac30
10 files changed, 1205 insertions, 0 deletions
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..ec5f07e
--- /dev/null
+++ b/COPYING
@@ -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
diff --git a/README b/README
new file mode 100644
index 0000000..c42f930
--- /dev/null
+++ b/README
@@ -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, "&lt;", sz - j);
+ j += 3;
+ } else if (src[i] == '>') {
+ strncpy(dst + j, "&gt;", sz - j);
+ j += 3;
+ } else if (src[i] == '"') {
+ strncpy(dst + j, "&quot;", sz - j);
+ j += 5;
+ } else if (src[i] == '&') {
+ strncpy(dst + j, "&amp;", 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