summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordefanor <defanor@thunix.net>2024-04-22 16:56:50 +0300
committerdefanor <defanor@thunix.net>2024-04-22 16:56:50 +0300
commit140f86f243005bf0fcb8ea88f183507400d138a0 (patch)
treed1a7cc04cf1921e5de387b95eb90fc5178772558
Add the initial versionHEADmaster
Error handling can be improved, probably audio retrieval in the JS part can be better as well, but it generally works, and this should suffice as the initial version.
-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