From 4f49b16079b27d7ce633619e4cddfdf2bffbc952 Mon Sep 17 00:00:00 2001 From: cashmere Date: Fri, 4 Sep 2026 09:05:24 +0200 Subject: initial commit --- src/chat.c | 1478 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1478 insertions(+) create mode 100644 src/chat.c (limited to 'src/chat.c') diff --git a/src/chat.c b/src/chat.c new file mode 100644 index 0000000..f5b74a9 --- /dev/null +++ b/src/chat.c @@ -0,0 +1,1478 @@ +/* +** Copyright (c) 2020 D. Richard Hipp +** +** This program is free software; you can redistribute it and/or +** modify it under the terms of the Simplified BSD License (also +** known as the "2-Clause License" or "FreeBSD License".) +** +** This program is distributed in the hope that it will be useful, +** but without any warranty; without even the implied warranty of +** merchantability or fitness for a particular purpose. +** +** Author contact information: +** drh@hwaci.com +** http://www.hwaci.com/drh/ +** +******************************************************************************* +** +** This file contains code used to implement the Fossil chatroom. +** +** Initial design goals: +** +** * Keep it simple. This chatroom is not intended as a competitor +** or replacement for IRC, Discord, Telegram, Slack, etc. The goal +** is zero- or near-zero-configuration, not an abundance of features. +** +** * Intended as a place for insiders to have ephemeral conversations +** about a project. This is not a public gather place. Think +** "boardroom", not "corner pub". +** +** * One chatroom per repository. +** +** * Chat content lives in a single repository. It is never synced. +** Content expires and is deleted after a set interval (a week or so). +** +** Notification is accomplished using the "hanging GET" or "long poll" design +** in which a GET request is issued but the server does not send a reply until +** new content arrives. Newer Web Sockets and Server Sent Event protocols are +** more elegant, but are not compatible with CGI, and would thus complicate +** configuration. +*/ +#include "config.h" +#include +#include "chat.h" + +/* +** Outputs JS code to initialize a list of chat alert audio files for +** use by the chat front-end client. A handful of builtin files +** (from alerts/\*.wav) and all unversioned files matching +** alert-sounds/\*.{mp3,ogg,wav} are included. +*/ +static void chat_emit_alert_list(void){ + unsigned int i; + const char * azBuiltins[] = { + "builtin/alerts/plunk.wav", + "builtin/alerts/bflat2.wav", + "builtin/alerts/bflat3.wav", + "builtin/alerts/bloop.wav" + }; + CX("window.fossil.config.chat.alerts = [\n"); + for(i=0; i < sizeof(azBuiltins)/sizeof(azBuiltins[0]); ++i){ + CX("%s%!j", i ? ", " : "", azBuiltins[i]); + } + if( db_table_exists("repository","unversioned") ){ + Stmt q = empty_Stmt; + db_prepare(&q, "SELECT 'uv/'||name FROM unversioned " + "WHERE content IS NOT NULL " + "AND (name LIKE 'alert-sounds/%%.wav' " + "OR name LIKE 'alert-sounds/%%.mp3' " + "OR name LIKE 'alert-sounds/%%.ogg')"); + while(SQLITE_ROW==db_step(&q)){ + CX(", %!j", db_column_text(&q, 0)); + } + db_finalize(&q); + } + CX("\n];\n"); +} + +/* Settings that can be used to control chat */ +/* +** SETTING: chat-initial-history width=10 default=50 +** +** If this setting has an integer value of N, then when /chat first +** starts up it initializes the screen with the N most recent chat +** messages. If N is zero, then all chat messages are loaded. +*/ +/* +** SETTING: chat-keep-count width=10 default=50 +** +** When /chat is cleaning up older messages, it will always keep +** the most recent chat-keep-count messages, even if some of those +** messages are older than the discard threshold. If this value +** is zero, then /chat is free to delete all historic messages once +** they are old enough. +*/ +/* +** SETTING: chat-keep-days width=10 default=7 +** +** The /chat subsystem will try to discard messages that are older then +** chat-keep-days. The value of chat-keep-days can be a floating point +** number. So, for example, if you only want to keep chat messages for +** 12 hours, set this value to 0.5. +** +** A value of 0.0 or less means that messages are retained forever. +*/ +/* +** SETTING: chat-inline-images boolean default=on +** +** Specifies whether posted images in /chat should default to being +** displayed inline or as downloadable links. Each chat user can +** change this value for their current chat session in the UI. +*/ +/* +** SETTING: chat-poll-timeout width=10 default=420 +** +** On an HTTP request to /chat-poll, if there is no new content available, +** the reply is delayed waiting for new content to arrive. (This is the +** "long poll" strategy of event delivery to the client.) This setting +** determines approximately how long /chat-poll will delay before giving +** up and returning an empty reply. The default value is about 7 minutes, +** which works well for Fossil behind the althttpd web server. Other +** server environments may choose a longer or shorter delay. +** +** For maximum efficiency, it is best to choose the longest delay that +** does not cause timeouts in intermediate proxies or web server. +*/ +/* +** SETTING: chat-alert-sound width=10 +** +** This is the name of the builtin sound file to use for the alert tone. +** The value must be the name of a builtin WAV file. +*/ +/* +** SETTING: chat-timeline-user width=10 +** +** If this setting is defined and is not an empty string, then +** timeline events are posted to the chat as they arrive. The synthesized +** chat messages appear to come from the user identified by this setting, +** not the user on the timeline event. +** +** All chat messages that come from the chat-timeline-user are +** interpreted as text/x-fossil-wiki instead of as text/x-markdown. +** For this reason, the chat-timeline-user name should probably not be +** a real user. +*/ +/* +** WEBPAGE: chat loadavg-exempt +** +** Start up a browser-based chat session. +** +** This is the main page that humans use to access the chatroom. Simply +** point a web-browser at /chat and the screen fills with the latest +** chat messages, and waits for new ones. +** +** Other /chat-OP pages are used by XHR requests from this page to +** send new chat message, delete older messages, or poll for changes. +*/ +void chat_webpage(void){ + char *zAlert; + char *zProjectName; + char * zInputPlaceholder0; /* Common text input placeholder value */ + + login_check_credentials(); + if( !g.perm.Chat ){ + login_needed(g.anon.Chat); + return; + } + zAlert = mprintf("%s/builtin/%s", g.zBaseURL, + db_get("chat-alert-sound","alerts/plunk.wav")); + zProjectName = db_get("project-name","Unnamed project"); + zInputPlaceholder0 = + mprintf("Type markdown-formatted message for %h.", zProjectName); + style_set_current_feature("chat"); + style_header("Chat"); + @
+ @
+ @ + @ + @ + @
+ @ 👁 + @ 🔍 + @ 📎 + @ ⚙ + @ 📤 + @
+ @
+ @
+ @ + @
+ @
+ @
+ @ + @ + @ + @ + @
+ /* New chat messages get inserted immediately after this element */ + @ + @
+ @ + @ + fossil_free(zProjectName); + fossil_free(zInputPlaceholder0); + builtin_fossil_js_bundle_or("popupwidget", "storage", "fetch", + "pikchr", "confirmer", "copybutton", + NULL); + /* Always in-line the javascript for the chat page */ + @ + builtin_request_js("fossil.page.chat.js"); + style_finish_page(); +} + +/* +** Definition of repository tables used by chat +*/ +static const char zChatSchema1[] = +@ CREATE TABLE repository.chat( +@ msgid INTEGER PRIMARY KEY AUTOINCREMENT, +@ mtime JULIANDAY, -- Time for this entry - Julianday Zulu +@ lmtime TEXT, -- Client YYYY-MM-DDZHH:MM:SS when message originally sent +@ xfrom TEXT, -- Login of the sender +@ xmsg TEXT, -- Raw, unformatted text of the message +@ fname TEXT, -- Filename of the uploaded file, or NULL +@ fmime TEXT, -- MIMEType of the upload file, or NULL +@ mdel INT, -- msgid of another message to delete +@ file BLOB -- Text of the uploaded file, or NULL +@ ); +; + + +/* +** Create or rebuild the /chat search index. Requires that the +** repository.chat table exists. If bForce is true, it will drop the +** chatfts1 table and recreate/reindex it. If bForce is 0, it will +** only index the chat content if the chatfts1 table does not already +** exist. +*/ +void chat_rebuild_index(int bForce){ + if( !db_table_exists("repository","chat") ) return; + if( bForce!=0 ){ + db_multi_exec("DROP TABLE IF EXISTS chatfts1"); + } + if( bForce!=0 || !db_table_exists("repository", "chatfts1") ){ + const int tokType = search_tokenizer_type(0); + const char *zTokenizer = search_tokenize_arg_for_type( + tokType==FTS5TOK_NONE ? FTS5TOK_PORTER : tokType + /* Special case: if fts search is disabled for the main repo + ** content, use a default tokenizer here. */ + ); + assert( zTokenizer && zTokenizer[0] ); + db_multi_exec( + "CREATE VIRTUAL TABLE repository.chatfts1 USING fts5(" + " xmsg, content=chat, content_rowid=msgid%s" + ");" + "INSERT INTO repository.chatfts1(chatfts1) VALUES('rebuild');", + zTokenizer/*safe-for-%s*/ + ); + } +} + +/* +** Make sure the repository data tables used by chat exist. Create +** them if they do not. Set up TEMP triggers (if needed) to update the +** chatfts1 table as the chat table is updated. +*/ +void chat_create_tables(void){ + if( !db_table_exists("repository","chat") ){ + db_multi_exec(zChatSchema1/*works-like:""*/); + }else if( !db_table_has_column("repository","chat","lmtime") ){ + if( !db_table_has_column("repository","chat","mdel") ){ + db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT"); + } + db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT"); + } + chat_rebuild_index(0); + db_multi_exec( + "CREATE TEMP TRIGGER IF NOT EXISTS chat_ai AFTER INSERT ON chat BEGIN " + " INSERT INTO chatfts1(rowid, xmsg) VALUES(new.msgid, new.xmsg);" + "END;" + "CREATE TEMP TRIGGER IF NOT EXISTS chat_ad AFTER DELETE ON chat BEGIN " + " INSERT INTO chatfts1(chatfts1, rowid, xmsg) " + " VALUES('delete', old.msgid, old.xmsg);" + "END;" + ); +} + +/* +** Delete old content from the chat table. +*/ +static void chat_purge(void){ + int mxCnt = db_get_int("chat-keep-count",50); + double mxDays = atof(db_get("chat-keep-days","7")); + double rAge; + int msgid; + rAge = db_double(0.0, "SELECT julianday('now')-mtime FROM chat" + " ORDER BY msgid LIMIT 1"); + if( rAge>mxDays ){ + msgid = db_int(0, "SELECT msgid FROM chat" + " ORDER BY msgid DESC LIMIT 1 OFFSET %d", mxCnt); + if( msgid>0 ){ + Stmt s; + db_multi_exec("PRAGMA secure_delete=ON;"); + db_prepare(&s, + "DELETE FROM chat WHERE mtimelogging in.\""); + if(fAsMessageList){ + CX("}]}"); + }else{ + CX("}"); + } + fossil_free(zTime); +} + +/* +** Like chat_emit_permissions_error() but emits a single +** /chat-message-format JSON object about a CSRF violation. +*/ +static void chat_emit_csrf_error(void){ + char * zTime = cgi_iso8601_datestamp(); + cgi_set_content_type("application/json"); + CX("{"); + CX("\"isError\": true, \"xfrom\": null,"); + CX("\"mtime\": %!j, \"lmtime\": %!j,", zTime, zTime); + CX("\"xmsg\": \"CSRF validation failure.\""); + CX("}"); + fossil_free(zTime); +} + +/* +** WEBPAGE: chat-send hidden loadavg-exempt +** +** This page receives (via XHR) a new chat-message and/or a new file +** to be entered into the chat history. +** +** On success it responds with an empty response: the new message +** should be fetched via /chat-poll. On error, e.g. login expiry, +** it emits a JSON response in the same form as described for +** /chat-poll errors, but as a standalone object instead of a +** list of objects. +** +** Requests to this page should be POST, not GET. POST parameters +** include: +** +** msg The (Markdown) text of the message to be sent +** +** file The content of the file attachment +** +** lmtime ISO-8601 formatted date-time string showing the local time +** of the sender. +** +** At least one of the "msg" or "file" POST parameters must be provided. +*/ +void chat_send_webpage(void){ + int nByte; + const char *zMsg; + const char *zUserName; + login_check_credentials(); + if( 0==g.perm.Chat ) { + chat_emit_permissions_error(0); + return; + }else if( g.eAuthMethod==AUTH_COOKIE && 0==cgi_csrf_safe(1) ){ + chat_emit_csrf_error(); + return; + } + zUserName = (g.zLogin && g.zLogin[0]) ? g.zLogin : "nobody"; + nByte = atoi(PD("file:bytes","0")); + zMsg = PD("msg",""); + db_begin_write(); + db_unprotect(PROTECT_READONLY); + chat_create_tables(); + chat_purge(); + if( nByte==0 ){ + if( zMsg[0] ){ + db_multi_exec( + "INSERT INTO chat(mtime,lmtime,xfrom,xmsg)" + "VALUES(julianday('now'),%Q,%Q,%Q)", + P("lmtime"), zUserName, zMsg + ); + } + }else{ + Stmt q; + Blob b; + db_prepare(&q, + "INSERT INTO chat(mtime,lmtime,xfrom,xmsg,file,fname,fmime)" + "VALUES(julianday('now'),%Q,%Q,%Q,:file,%Q,%Q)", + P("lmtime"), zUserName, zMsg, PD("file:filename",""), + PD("file:mimetype","application/octet-stream")); + blob_init(&b, P("file"), nByte); + db_bind_blob(&q, ":file", &b); + db_step(&q); + db_finalize(&q); + blob_reset(&b); + } + db_commit_transaction(); + db_protect_pop(); +} + +/* +** This routine receives raw (user-entered) message text and +** transforms it into HTML that is safe to insert using innerHTML. As +** of 2021-09-19, it does so by using wiki_convert() or +** markdown_to_html() to convert wiki/markdown-formatted zMsg to HTML. +** +** Space to hold the returned string is obtained from fossil_malloc() +** and must be freed by the caller. +*/ +static char *chat_format_to_html(const char *zMsg, int isWiki){ + Blob out; + blob_init(&out, "", 0); + if( zMsg==0 || zMsg[0]==0 ){ + /* No-op */ + }else if( isWiki ){ + /* Used for chat-timeline-user. The zMsg is text/x-fossil-wiki. */ + Blob bIn; + blob_init(&bIn, zMsg, (int)strlen(zMsg)); + wiki_convert(&bIn, &out, WIKI_INLINE); + }else{ + /* The common case: zMsg is text/x-markdown */ + Blob bIn; + blob_init(&bIn, zMsg, (int)strlen(zMsg)); + markdown_to_html(&bIn, NULL, &out); + } + return blob_str(&out); +} + +/* +** COMMAND: test-chat-formatter +** +** Usage: %fossil test-chat-formatter ?OPTIONS? STRING ... +** +** Transform each argument string into HTML that will display the +** chat message. This is used to test the formatter and to verify +** that a malicious message text will not cause HTML or JS injection +** into the chat display in a browser. +** +** Options: +** +** -w|--wiki Assume fossil wiki format instead of markdown +*/ +void chat_test_formatter_cmd(void){ + int i; + char *zOut; + int const isWiki = find_option("w","wiki",0)!=0; + db_find_and_open_repository(0,0); + g.perm.Hyperlink = 1; + for(i=2; i