summaryrefslogtreecommitdiff
path: root/bwchat_cgi.c
diff options
context:
space:
mode:
Diffstat (limited to 'bwchat_cgi.c')
-rw-r--r--bwchat_cgi.c630
1 files changed, 630 insertions, 0 deletions
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;
+}