summaryrefslogtreecommitdiff
path: root/src/chat.c
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-09-04 09:05:24 +0200
committercashmere <cashmere@autistici.org>2026-09-04 09:05:24 +0200
commit4f49b16079b27d7ce633619e4cddfdf2bffbc952 (patch)
treec296c1e7f3c3e2f6859c6d3a1b1b208779b89679 /src/chat.c
downloadrelic-master.tar.gz
initial commitHEADmaster
Diffstat (limited to 'src/chat.c')
-rw-r--r--src/chat.c1478
1 files changed, 1478 insertions, 0 deletions
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 <assert.h>
+#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");
+ @ <div id='chat-input-area'>
+ @ <div id='chat-input-line-wrapper' class='compact'>
+ @ <input type="text" id="chat-input-field-single" \
+ @ data-placeholder0="%h(zInputPlaceholder0)" \
+ @ data-placeholder="%h(zInputPlaceholder0)" \
+ @ class="chat-input-field"></input>
+ @ <textarea id="chat-input-field-multi" \
+ @ data-placeholder0="%h(zInputPlaceholder0)" \
+ @ data-placeholder="%h(zInputPlaceholder0)" \
+ @ class="chat-input-field hidden"></textarea>
+ @ <div contenteditable id="chat-input-field-x" \
+ @ data-placeholder0="%h(zInputPlaceholder0)" \
+ @ data-placeholder="%h(zInputPlaceholder0)" \
+ @ class="chat-input-field hidden"></div>
+ @ <div id='chat-buttons-wrapper'>
+ @ <span class='cbutton' id="chat-button-preview" \
+ @ title="Preview message (Shift-Enter)">&#128065;</span>
+ @ <span class='cbutton' id="chat-button-search" \
+ @ title="Search chat history">&#x1f50d;</span>
+ @ <span class='cbutton' id="chat-button-attach" \
+ @ title="Attach file to message">&#x1f4ce;</span>
+ @ <span class='cbutton' id="chat-button-settings" \
+ @ title="Configure chat">&#9881;</span>
+ @ <span class='cbutton' id="chat-button-submit" \
+ @ title="Send message (Ctrl-Enter)">&#128228;</span>
+ @ </div>
+ @ </div>
+ @ <div id='chat-input-file-area'>
+ @ <div class='file-selection-wrapper hidden'>
+ @ <input type="file" name="file" id="chat-input-file">
+ @ </div>
+ @ <div id="chat-drop-details"></div>
+ @ </div>
+ @ </div>
+ @ <div id='chat-user-list-wrapper' class='hidden'>
+ @ <div class='legend'>
+ @ <span class='help-buttonlet'>
+ @ Users who have messages in the currently-loaded list.<br><br>
+ @ <strong>Tap a user name</strong> to filter messages
+ @ on that user and tap again to clear the filter.<br><br>
+ @ <strong>Tap the title</strong> of this widget to toggle
+ @ the list on and off.
+ @ </span>
+ @ <span>Active users (sorted by last message time)</span>
+ @ </div>
+ @ <div id='chat-user-list'></div>
+ @ </div>
+ @ <div id='chat-preview' class='hidden chat-view'>
+ @ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
+ @ <div id='chat-preview-content'></div>
+ @ <div class='button-bar'><button class='action-close'>Close Preview</button></div>
+ @ </div>
+ @ <div id='chat-config' class='hidden chat-view'>
+ @ <div id='chat-config-options'></div>
+ /* ^^^populated client-side */
+ @ <div class='button-bar'><button class='action-close'>Close Settings</button></div>
+ @ </div>
+ @ <div id='chat-search' class='hidden chat-view'>
+ @ <div id='chat-search-content'></div>
+ /* ^^^populated client-side */
+ @ <div class='button-bar'>
+ @ <button class='action-clear'>Clear results</button>
+ @ <button class='action-close'>Close Search</button>
+ @ </div>
+ @ </div>
+ @ <div id='chat-messages-wrapper' class='chat-view'>
+ /* New chat messages get inserted immediately after this element */
+ @ <span id='message-inject-point'></span>
+ @ </div>
+ @ <div id='chat-zoom' class='hidden chat-view'>
+ @ <div id='chat-zoom-content'></div>
+ @ <div class='button-bar'><button class='action-close'>Close Zoom</button></div>
+ @ </div>
+ @ <span id='chat-zoom-marker' class='hidden'><!-- placeholder marker for zoomed msg --></span>
+ 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 */
+ @ <script nonce="%h(style_nonce())">/* chat.c:%d(__LINE__) */
+ /* We need an onload handler to ensure that window.fossil is
+ initialized before the chat init code runs. */
+ @ window.addEventListener('load', function(){
+ @ document.body.classList.add('chat');
+ @ /*^^^for skins which add their own BODY tag */;
+ @ window.fossil.config.chat = {
+ @ fromcli: %h(PB("cli")?"true":"false"),
+ @ alertSound: "%h(zAlert)",
+ @ initSize: %d(db_get_int("chat-initial-history",50)),
+ @ imagesInline: !!%d(db_get_boolean("chat-inline-images",1)),
+ @ pollTimeout: %d(db_get_int("chat-poll-timeout",420))
+ @ };
+ ajax_emit_js_preview_modes(0);
+ chat_emit_alert_list();
+ @ }, false);
+ @ </script>
+ 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 mtime<julianday('now')-:mxage"
+ " AND msgid<%d", msgid);
+ db_bind_double(&s, ":mxage", mxDays);
+ db_step(&s);
+ db_finalize(&s);
+ }
+ }
+}
+
+/*
+** Sets the current CGI response type to application/json then emits a
+** JSON-format error message object. If fAsMessageList is true then
+** the object is output using the list format described for chat-poll,
+** else it is emitted as a single object in that same format.
+*/
+static void chat_emit_permissions_error(int fAsMessageList){
+ char * zTime = cgi_iso8601_datestamp();
+ cgi_set_content_type("application/json");
+ if(fAsMessageList){
+ CX("{\"msgs\":[{");
+ }else{
+ CX("{");
+ }
+ CX("\"isError\": true, \"xfrom\": null,");
+ CX("\"mtime\": %!j, \"lmtime\": %!j,", zTime, zTime);
+ CX("\"xmsg\": \"Missing permissions or not logged in. "
+ "Try <a href='%R/login?g=chat'>logging in</a>.\"");
+ 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<g.argc; i++){
+ zOut = chat_format_to_html(g.argv[i], isWiki);
+ fossil_print("[%d]: %s\n", i-1, zOut);
+ fossil_free(zOut);
+ }
+}
+
+/*
+** The SQL statement passed as the first argument should return zero or
+** more rows of data, each of which represents a single message from the
+** "chat" table. The rows returned should be similar to those returned
+** by:
+**
+** SELECT msgid,
+** datetime(mtime),
+** xfrom,
+** xmsg,
+** octet_length(file),"
+** fname,
+** fmime,
+** mdel,
+** lmtime
+** FROM chat;
+**
+** This function loops through all rows returned by statement p, adding
+** a record to the JSON stored in argument pJson for each. See comments
+** above function chat_poll_webpage() for a description of the JSON records
+** added to pJson.
+*/
+static int chat_poll_rowstojson(
+ Stmt *p, /* Statement to read rows from */
+ int bRaw, /* True to return raw format xmsg */
+ Blob *pJson /* Append json array entries here */
+){
+ int cnt = 0;
+ const char *zChatUser = db_get("chat-timeline-user",0);
+ while( db_step(p)==SQLITE_ROW ){
+ int isWiki = 0; /* True if chat message is x-fossil-wiki */
+ int id = db_column_int(p, 0);
+ const char *zDate = db_column_text(p, 1);
+ const char *zFrom = db_column_text(p, 2);
+ const char *zRawMsg = db_column_text(p, 3);
+ int nByte = db_column_int(p, 4);
+ const char *zFName = db_column_text(p, 5);
+ const char *zFMime = db_column_text(p, 6);
+ int iToDel = db_column_int(p, 7);
+ const char *zLMtime = db_column_text(p, 8);
+ char *zMsg;
+ if(cnt++){
+ blob_append(pJson, ",\n", 2);
+ }
+ blob_appendf(pJson, "{\"msgid\":%d,", id);
+ blob_appendf(pJson, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
+ if( zLMtime && zLMtime[0] ){
+ blob_appendf(pJson, "\"lmtime\":%!j,", zLMtime);
+ }
+ blob_append(pJson, "\"xfrom\":", -1);
+ if(zFrom){
+ blob_appendf(pJson, "%!j,", zFrom);
+ isWiki = fossil_strcmp(zFrom,zChatUser)==0;
+ }else{
+ /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
+ blob_appendf(pJson, "null,");
+ isWiki = 0;
+ }
+ blob_appendf(pJson, "\"uclr\":%!j,",
+ isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
+
+ if(bRaw){
+ blob_appendf(pJson, "\"xmsg\":%!j,", zRawMsg);
+ }else{
+ zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
+ blob_appendf(pJson, "\"xmsg\":%!j,", zMsg);
+ fossil_free(zMsg);
+ }
+
+ if( nByte==0 ){
+ blob_appendf(pJson, "\"fsize\":0");
+ }else{
+ blob_appendf(pJson, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
+ nByte, zFName, zFMime);
+ }
+
+ if( iToDel ){
+ blob_appendf(pJson, ",\"mdel\":%d}", iToDel);
+ }else{
+ blob_append(pJson, "}", 1);
+ }
+ }
+ db_reset(p);
+
+ return cnt;
+}
+
+/*
+** WEBPAGE: chat-poll hidden loadavg-exempt
+**
+** The chat page generated by /chat using an XHR to this page to
+** request new chat content. A typical invocation is:
+**
+** /chat-poll/N
+** /chat-poll?name=N
+**
+** The "name" argument should begin with an integer which is the largest
+** "msgid" that the chat page currently holds. If newer content is
+** available, this routine returns that content straight away. If no new
+** content is available, this webpage blocks until the new content becomes
+** available. In this way, the system implements "hanging-GET" or "long-poll"
+** style event notification. If no new content arrives after a delay of
+** approximately chat-poll-timeout seconds (default: 420), then reply is
+** sent with an empty "msg": field.
+**
+** If N is negative, then the return value is the N most recent messages.
+** Hence a request like /chat-poll/-100 can be used to initialize a new
+** chat session to just the most recent messages.
+**
+** Some webservers (althttpd) do not allow a term of the URL path to
+** begin with "-". Then /chat-poll/-100 cannot be used. Instead you
+** have to say "/chat-poll?name=-100".
+**
+** If the integer parameter "before" is passed in, it is assumed that
+** the client is requesting older messages, up to (but not including)
+** that message ID, in which case the next-oldest "n" messages
+** (default=chat-initial-history setting, equivalent to n=0) are
+** returned (negative n fetches all older entries). The client then
+** needs to take care to inject them at the end of the history rather
+** than the same place new messages go.
+**
+** If "before" is provided, "name" is ignored.
+**
+** If "raw" is provided, the "xmsg" text is sent back as-is, in
+** markdown format, rather than being HTML-ized. This is not used or
+** supported by fossil's own chat client but is intended for 3rd-party
+** clients. (Specifically, for Brad Harder's curses-based client.)
+**
+** The reply from this webpage is JSON that describes the new content.
+** Format of the json:
+**
+** | {
+** | "msgs":[
+** | {
+** | "msgid": integer // message id
+** | "mtime": text // When sent: YYYY-MM-DDTHH:MM:SSZ
+** | "lmtime: text // Sender's client-side YYYY-MM-DDTHH:MM:SS
+** | "xfrom": text // Login name of sender
+** | "uclr": text // Color string associated with the user
+** | "xmsg": text // HTML text of the message
+** | "fsize": integer // file attachment size in bytes
+** | "fname": text // Name of file attachment
+** | "fmime": text // MIME-type of file attachment
+** | "mdel": integer // message id of prior message to delete
+** | }
+** | ]
+** | }
+**
+** The "fname" and "fmime" fields are only present if "fsize" is greater
+** than zero. The "xmsg" field may be an empty string if "fsize" is zero.
+**
+** The "msgid" values will be in increasing order.
+**
+** The "mdel" will only exist if "xmsg" is an empty string and "fsize" is zero.
+**
+** The "lmtime" value might be unknown, in which case it is omitted.
+**
+** The messages are ordered oldest first unless "before" is provided, in which
+** case they are sorted newest first (to facilitate the client-side UI update).
+**
+** As a special case, if this routine encounters an error, e.g. the user's
+** permissions cannot be verified because their login cookie expired, the
+** request returns a slightly modified structure:
+**
+** | {
+** | "msgs":[
+** | {
+** | "isError": true,
+** | "xfrom": null,
+** | "xmsg": "error details"
+** | "mtime": as above,
+** | "ltime": same as mtime
+** | }
+** | ]
+** | }
+**
+** If the client gets such a response, it should display the message
+** in a prominent manner and then stop polling for new messages.
+*/
+void chat_poll_webpage(void){
+ Blob json; /* The json to be constructed and returned */
+ sqlite3_int64 dataVersion; /* Data version. Used for polling. */
+ const int iDelay = 1000; /* Delay until next poll (milliseconds) */
+ int nDelay; /* Maximum delay.*/
+ int msgid = atoi(PD("name","0"));
+ const int msgBefore = atoi(PD("before","0"));
+ int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
+ const int bRaw = P("raw")!=0;
+
+ Blob sql = empty_blob;
+ Stmt q1;
+ nDelay = db_get_int("chat-poll-timeout",420); /* Default about 7 minutes */
+ login_check_credentials();
+ if( !g.perm.Chat ) {
+ chat_emit_permissions_error(1);
+ return;
+ }
+ chat_create_tables();
+ cgi_set_content_type("application/json");
+ dataVersion = db_int64(0, "PRAGMA data_version");
+ blob_append_sql(&sql,
+ "SELECT msgid, datetime(mtime), xfrom, xmsg, octet_length(file),"
+ " fname, fmime, %s, lmtime"
+ " FROM chat ",
+ msgBefore>0 ? "0 as mdel" : "mdel");
+ if( msgid<=0 || msgBefore>0 ){
+ db_begin_write();
+ chat_purge();
+ db_commit_transaction();
+ }
+ if(msgBefore>0){
+ if(0==nLimit){
+ nLimit = db_get_int("chat-initial-history",50);
+ }
+ blob_append_sql(&sql,
+ " WHERE msgid<%d"
+ " ORDER BY msgid DESC "
+ "LIMIT %d",
+ msgBefore, nLimit>0 ? nLimit : -1
+ );
+ }else{
+ if( msgid<0 ){
+ msgid = db_int(0,
+ "SELECT msgid FROM chat WHERE mdel IS NOT true"
+ " ORDER BY msgid DESC LIMIT 1 OFFSET %d", -msgid);
+ }
+ blob_append_sql(&sql,
+ " WHERE msgid>%d"
+ " ORDER BY msgid",
+ msgid
+ );
+ }
+ db_prepare(&q1, "%s", blob_sql_text(&sql));
+ blob_reset(&sql);
+ blob_init(&json, "{\"msgs\":[\n", -1);
+ while( nDelay>0 ){
+ int cnt = chat_poll_rowstojson(&q1, bRaw, &json);
+ if( cnt || msgBefore>0 ){
+ break;
+ }
+ sqlite3_sleep(iDelay); nDelay--;
+ while( nDelay>0 ){
+ sqlite3_int64 newDataVers = db_int64(0,"PRAGMA repository.data_version");
+ if( newDataVers!=dataVersion ){
+ dataVersion = newDataVers;
+ break;
+ }
+ sqlite3_sleep(iDelay); nDelay--;
+ }
+ } /* Exit by "break" */
+ db_finalize(&q1);
+ blob_append(&json, "\n]}", 3);
+ cgi_set_content(&json);
+ return;
+}
+
+
+/*
+** WEBPAGE: chat-query hidden loadavg-exempt
+*/
+void chat_query_webpage(void){
+ Blob json; /* The json to be constructed and returned */
+ Blob sql = empty_blob;
+ Stmt q1;
+ int nLimit = atoi(PD("n","500"));
+ int iFirst = atoi(PD("i","0"));
+ const char *zQuery = PD("q", "");
+ i64 iMin = 0;
+ i64 iMax = 0;
+
+ login_check_credentials();
+ if( !g.perm.Chat ) {
+ chat_emit_permissions_error(1);
+ return;
+ }
+ chat_create_tables();
+ cgi_set_content_type("application/json");
+
+ if( zQuery[0] ){
+ iMax = db_int64(0, "SELECT max(msgid) FROM chat");
+ iMin = db_int64(0, "SELECT min(msgid) FROM chat");
+ if( '#'==zQuery[0] ){
+ /* Assume we're looking for an exact msgid match. */
+ ++zQuery;
+ blob_append_sql(&sql,
+ "SELECT msgid, datetime(mtime), xfrom, "
+ " xmsg, octet_length(file), fname, fmime, mdel, lmtime "
+ " FROM chat WHERE msgid=+%Q",
+ zQuery
+ );
+ }else{
+ char * zPat = search_simplify_pattern(zQuery);
+ blob_append_sql(&sql,
+ "SELECT * FROM ("
+ "SELECT c.msgid, datetime(c.mtime), c.xfrom, "
+ " highlight(chatfts1, 0, '<span class=\"match\">', '</span>'), "
+ " octet_length(c.file), c.fname, c.fmime, c.mdel, c.lmtime "
+ " FROM chatfts1(%Q) f, chat c "
+ " WHERE f.rowid=c.msgid"
+ " ORDER BY f.rowid DESC LIMIT %d"
+ ") ORDER BY 1 ASC", zPat, nLimit
+ );
+ fossil_free(zPat);
+ }
+ }else{
+ blob_append_sql(&sql,
+ "SELECT msgid, datetime(mtime), xfrom, "
+ " xmsg, octet_length(file), fname, fmime, mdel, lmtime"
+ " FROM chat WHERE msgid>=%d LIMIT %d",
+ iFirst, nLimit
+ );
+ }
+
+ db_prepare(&q1, "%s", blob_sql_text(&sql));
+ blob_reset(&sql);
+ blob_init(&json, "{\"msgs\":[\n", -1);
+ chat_poll_rowstojson(&q1, 0, &json);
+ db_finalize(&q1);
+ blob_appendf(&json, "\n], \"first\":%lld, \"last\":%lld}", iMin, iMax);
+ cgi_set_content(&json);
+ return;
+}
+
+/*
+** WEBPAGE: chat-fetch-one hidden loadavg-exempt
+**
+** /chat-fetch-one/N
+**
+** Fetches a single message with the given ID, if available.
+**
+** Options:
+**
+** raw = the xmsg field will be returned unparsed.
+**
+** Response is either a single object in the format returned by
+** /chat-poll (without the wrapper array) or a JSON-format error
+** response, as documented for ajax_route_error().
+*/
+void chat_fetch_one(void){
+ Blob json = empty_blob; /* The json to be constructed and returned */
+ const int fRaw = PD("raw",0)!=0;
+ const int msgid = atoi(PD("name","0"));
+ const char *zChatUser;
+ int isWiki;
+ Stmt q;
+ login_check_credentials();
+ if( !g.perm.Chat ) {
+ chat_emit_permissions_error(0);
+ return;
+ }
+ zChatUser = db_get("chat-timeline-user",0);
+ chat_create_tables();
+ cgi_set_content_type("application/json");
+ db_prepare(&q,
+ "SELECT datetime(mtime), xfrom, xmsg, octet_length(file),"
+ " fname, fmime, lmtime"
+ " FROM chat WHERE msgid=%d AND mdel IS NULL",
+ msgid);
+ if(SQLITE_ROW==db_step(&q)){
+ const char *zDate = db_column_text(&q, 0);
+ const char *zFrom = db_column_text(&q, 1);
+ const char *zRawMsg = db_column_text(&q, 2);
+ const int nByte = db_column_int(&q, 3);
+ const char *zFName = db_column_text(&q, 4);
+ const char *zFMime = db_column_text(&q, 5);
+ const char *zLMtime = db_column_text(&q, 7);
+ blob_appendf(&json,"{\"msgid\": %d,", msgid);
+
+ blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
+ if( zLMtime && zLMtime[0] ){
+ blob_appendf(&json, "\"lmtime\":%!j,", zLMtime);
+ }
+ blob_append(&json, "\"xfrom\":", -1);
+ if(zFrom){
+ blob_appendf(&json, "%!j,", zFrom);
+ isWiki = fossil_strcmp(zFrom, zChatUser)==0;
+ }else{
+ /* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
+ blob_appendf(&json, "null,");
+ isWiki = 0;
+ }
+ blob_appendf(&json, "\"uclr\":%!j,",
+ isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
+ blob_append(&json,"\"xmsg\":", 7);
+ if(fRaw){
+ blob_appendf(&json, "%!j,", zRawMsg);
+ }else{
+ char * zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
+ blob_appendf(&json, "%!j,", zMsg);
+ fossil_free(zMsg);
+ }
+ if( nByte==0 ){
+ blob_appendf(&json, "\"fsize\":0");
+ }else{
+ blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
+ nByte, zFName, zFMime);
+ }
+ blob_append(&json,"}",1);
+ cgi_set_content(&json);
+ }else{
+ ajax_route_error(404,"Chat message #%d not found.", msgid);
+ }
+ db_finalize(&q);
+}
+
+/*
+** WEBPAGE: chat-download hidden loadavg-exempt
+**
+** Download the CHAT.FILE attachment associated with a single chat
+** entry. The "name" query parameter begins with an integer that
+** identifies the particular chat message. The integer may be followed
+** by a / and a filename, which will (A) indicate to the browser to
+** use the indicated name when saving the file and (B) be used to
+** guess the mimetype in some particular cases involving the "render"
+** flag.
+**
+** If the "render" URL parameter is provided, the blob has a size
+** greater than zero, and blob meets one of the following conditions
+** then the fossil-rendered form of that content is returned, rather
+** than the original:
+**
+** - Mimetype is text/x-markdown or text/markdown: emit HTML.
+**
+** - Mimetype is text/x-fossil-wiki or P("name") ends with ".wiki":
+** emit HTML.
+**
+** - Mimetype is text/x-pikchr or P("name") ends with ".pikchr": emit
+** image/svg+xml if rendering succeeds or text/html if rendering
+** fails.
+*/
+void chat_download_webpage(void){
+ int msgid;
+ int bCheckedMimetype = 0; /* true to bypass the text/... mimetype
+ ** check at the end */
+ Blob r; /* file content */
+ const char *zMime; /* file mimetype */
+ const char *zName = PD("name","0");
+ login_check_credentials();
+ if( !g.perm.Chat ){
+ style_header("Chat Not Authorized");
+ @ <h1>Not Authorized</h1>
+ @ <p>You do not have permission to use the chatroom on this
+ @ repository.</p>
+ style_finish_page();
+ return;
+ }
+ chat_create_tables();
+ msgid = atoi(zName);
+ blob_zero(&r);
+ zMime = db_text(0, "SELECT fmime FROM chat wHERE msgid=%d", msgid);
+ if( zMime==0 ) return;
+ db_blob(&r, "SELECT file FROM chat WHERE msgid=%d", msgid);
+ if( r.nUsed>0 && P("render")!=0 ){
+ /* Maybe return fossil-rendered form of the content. */
+ Blob r2 = BLOB_INITIALIZER; /* output target for rendering */
+ const char * zMime2 = 0; /* adjusted response mimetype */
+ if(fossil_strcmp(zMime, "text/x-markdown")==0
+ /* Firefox uploads md files with the mimetype text/markdown */
+ || fossil_strcmp(zMime, "text/markdown")==0){
+ markdown_to_html(&r, 0, &r2);
+ safe_html(&r2);
+ zMime2 = "text/html";
+ bCheckedMimetype = 1;
+ }else if(fossil_strcmp(zMime, "text/x-fossil-wiki")==0
+ || sqlite3_strglob("*.wiki", zName)==0){
+ /* .wiki files get uploaded as application/octet-stream */
+ wiki_convert(&r, &r2, 0);
+ zMime2 = "text/html";
+ bCheckedMimetype = 1;
+ }else if(fossil_strcmp(zMime, "text/x-pikchr")==0
+ || sqlite3_strglob("*.pikchr",zName)==0){
+ /* .pikchr files get uploaded as application/octet-stream */
+ const char *zPikchr = blob_str(&r);
+ int w = 0, h = 0;
+ char *zOut = pikchr(zPikchr, "pikchr", 0, &w, &h);
+ if(zOut){
+ blob_append(&r2, zOut, -1);
+ }
+ zMime2 = w>0 ? "image/svg+xml" : "text/html";
+ free(zOut);
+ bCheckedMimetype = 1;
+ }
+ if(r2.aData!=0){
+ blob_swap(&r, &r2);
+ blob_reset(&r2);
+ zMime = zMime2;
+ }
+ }
+ if( bCheckedMimetype==0 && sqlite3_strglob("text/*", zMime)==0 ){
+ /* The problem: both Chrome and Firefox upload *.patch with
+ ** the mimetype text/x-patch, whereas we very often use that
+ ** name glob for fossil-format patches. That causes such files
+ ** to attempt to render in the browser when clicked via
+ ** download links in chat.
+ **
+ ** The workaround: */
+ if( looks_like_binary(&r) ){
+ zMime = "application/octet-stream";
+ }
+ }
+ cgi_set_content_type(zMime);
+ cgi_set_content(&r);
+}
+
+
+/*
+** WEBPAGE: chat-delete hidden loadavg-exempt
+**
+** Delete the chat entry identified by the name query parameter.
+** Invoking fetch("chat-delete/"+msgid) from javascript in the client
+** will delete a chat entry from the CHAT table.
+**
+** This routine both deletes the identified chat entry and also inserts
+** a new entry with the current timestamp and with:
+**
+** * xmsg = NULL
+**
+** * file = NULL
+**
+** * mdel = The msgid of the row that was deleted
+**
+** This new entry will then be propagated to all listeners so that they
+** will know to delete their copies of the message too.
+*/
+void chat_delete_webpage(void){
+ int mdel;
+ char *zOwner;
+ login_check_credentials();
+ if( !g.perm.Chat ) return;
+ chat_create_tables();
+ mdel = atoi(PD("name","0"));
+ zOwner = db_text(0, "SELECT xfrom FROM chat WHERE msgid=%d", mdel);
+ if( zOwner==0 ) return;
+ if( fossil_strcmp(zOwner, g.zLogin)!=0 && !g.perm.Admin ) return;
+ db_multi_exec(
+ "PRAGMA secure_delete=ON;\n"
+ "BEGIN;\n"
+ "DELETE FROM chat WHERE msgid=%d;\n"
+ "INSERT INTO chat(mtime, xfrom, mdel)"
+ " VALUES(julianday('now'), %Q, %d);\n"
+ "COMMIT;",
+ mdel, g.zLogin, mdel
+ );
+}
+
+/*
+** WEBPAGE: chat-backup hidden
+**
+** Download an SQLite database containing all chat content with a
+** message-id larger than the "msgid" query parameter. Setup
+** privilege is required to use this URL.
+**
+** This is used to implement the "fossil chat pull" command.
+*/
+void chat_backup_webpage(void){
+ int msgid;
+ unsigned char *pDb = 0;
+ sqlite3_int64 szDb = 0;
+ Blob chatDb;
+ login_check_credentials();
+ if( !g.perm.Setup ) return;
+ msgid = atoi(PD("msgid","0"));
+ db_multi_exec(
+ "ATTACH ':memory:' AS mem1;\n"
+ "PRAGMA mem1.page_size=512;\n"
+ "CREATE TABLE mem1.chat AS SELECT * FROM repository.chat WHERE msgid>%d;\n",
+ msgid
+ );
+ pDb = sqlite3_serialize(g.db, "mem1", &szDb, 0);
+ if( pDb==0 ){
+ fossil_fatal("Out of memory");
+ }
+ blob_init(&chatDb, (const char*)pDb, (int)szDb);
+ cgi_set_content_type("application/x-sqlite3");
+ cgi_set_content(&chatDb);
+}
+
+/*
+** SQL Function: chat_msg_from_event(TYPE,OBJID,USER,MSG)
+**
+** This function returns HTML text that describes an entry from the EVENT
+** table (that is, a timeline event) for display in chat. Parameters:
+**
+** TYPE The event type. 'ci', 'w', 't', 'g', and so forth
+** OBJID EVENT.OBJID
+** USER coalesce(EVENT.EUSER,EVENT.USER)
+** MSG coalesce(EVENT.ECOMMENT, EVENT.COMMENT)
+**
+** This function is intended to be called by the temp.chat_trigger1 trigger
+** which is created by alert_create_trigger() routine.
+*/
+void chat_msg_from_event(
+ sqlite3_context *context,
+ int argc,
+ sqlite3_value **argv
+){
+ const char *zType = (const char*)sqlite3_value_text(argv[0]);
+ int rid = sqlite3_value_int(argv[1]);
+ const char *zUser = (const char*)sqlite3_value_text(argv[2]);
+ const char *zMsg = (const char*)sqlite3_value_text(argv[3]);
+ char *zRes = 0;
+
+ if( zType==0 || zUser==0 || zMsg==0 ) return;
+ if( zType[0]=='c' ){
+ /* Check-ins */
+ char *zBranch;
+ char *zUuid;
+
+ zBranch = db_text(0,
+ "SELECT value FROM tagxref"
+ " WHERE tagxref.rid=%d"
+ " AND tagxref.tagid=%d"
+ " AND tagxref.tagtype>0",
+ rid, TAG_BRANCH);
+ zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid);
+ zRes = mprintf("%W (check-in: <a href='%R/info/%S'>%S</a>, "
+ "user: <a href='%R/timeline?u=%t&c=%S'>%h</a>, "
+ "branch: <a href='%R/timeline?r=%t&c=%S'>%h</a>)",
+ zMsg,
+ zUuid, zUuid,
+ zUser, zUuid, zUser,
+ zBranch, zUuid, zBranch
+ );
+ fossil_free(zBranch);
+ fossil_free(zUuid);
+ }else if( zType[0]=='w' ){
+ /* Wiki page changes */
+ char *zUuid = rid_to_uuid(rid);
+ wiki_hyperlink_override(zUuid);
+ if( zMsg[0]=='-' ){
+ zRes = mprintf("Delete wiki page <a href='%R/whistory?name=%t'>%h</a>",
+ zMsg+1, zMsg+1);
+ }else if( zMsg[0]=='+' ){
+ zRes = mprintf("Added wiki page <a href='%R/whistory?name=%t'>%h</a>",
+ zMsg+1, zMsg+1);
+ }else if( zMsg[0]==':' ){
+ zRes = mprintf("<a href='%R/wdiff?id=%!S'>Changes</a> to wiki page "
+ "<a href='%R/whistory?name=%t'>%h</a>",
+ zUuid, zMsg+1, zMsg+1);
+ }else{
+ zRes = mprintf("%W", zMsg);
+ }
+ wiki_hyperlink_override(0);
+ fossil_free(zUuid);
+ }else if( zType[0]=='f' ){
+ /* Forum changes */
+ char *zUuid = rid_to_uuid(rid);
+ zRes = mprintf( "%W (artifact: <a href='%R/info/%S'>%S</a>, "
+ "user: <a href='%R/timeline?u=%t&c=%S'>%h</a>)",
+ zMsg, zUuid, zUuid, zUser, zUuid, zUser);
+ fossil_free(zUuid);
+ }else{
+ /* Anything else */
+ zRes = mprintf("%W", zMsg);
+ }
+ if( zRes ){
+ sqlite3_result_text(context, zRes, -1, fossil_free);
+ }
+}
+
+
+/*
+** COMMAND: chat
+**
+** Usage: %fossil chat [SUBCOMMAND] [--remote URL] [ARGS...]
+**
+** This command performs actions associated with the /chat instance
+** on the default remote Fossil repository (the Fossil repository whose
+** URL shows when you run the "fossil remote" command) or to the URL
+** specified by the --remote option. If there is no default remote
+** Fossil repository and the --remote option is omitted, then this
+** command fails with an error.
+**
+** Subcommands:
+**
+** > fossil chat
+**
+** When there is no SUBCOMMAND (when this command is simply "fossil chat")
+** the response is to bring up a web-browser window to the chatroom
+** on the default system web-browser. You can accomplish the same by
+** typing the appropriate URL into the web-browser yourself. This
+** command is merely a convenience for command-line oriented people.
+**
+** > fossil chat pull
+**
+** Copy chat content from the server down into the local clone,
+** as a backup or archive. Setup privilege is required on the server.
+**
+** --all Download all chat content. Normally only
+** previously undownloaded content is retrieved.
+** --debug Additional debugging output
+** --out DATABASE Store CHAT table in separate database file
+** DATABASE rather than adding to local clone
+** --unsafe Allow the use of unencrypted http://
+**
+** > fossil chat send [ARGUMENTS]
+**
+** This command sends a new message to the chatroom. The message
+** to be sent is determined by arguments as follows:
+**
+** -f|--file FILENAME File to attach to the message
+** --as FILENAME2 Causes --file FILENAME to be sent with
+** the attachment name FILENAME2
+** -m|--message TEXT Text of the chat message
+** --remote URL Send to this remote URL
+** --unsafe Allow the use of unencrypted http://
+**
+** > fossil chat url
+**
+** Show the default URL used to access the chat server.
+**
+** > fossil chat purge
+**
+** Remove chat messages that are older than chat-keep-days and
+** which are not one of the most recent chat-keep-count message.
+**
+** > fossil chat reindex
+**
+** Rebuild the full-text search index for chat
+**
+** Additional subcommands may be added in the future.
+*/
+void chat_command(void){
+ const char *zUrl = find_option("remote",0,1);
+ int urlFlags = 0;
+ int isDefaultUrl = 0;
+ int i;
+
+ db_find_and_open_repository(0,0);
+ if( zUrl ){
+ urlFlags = URL_PROMPT_PW;
+ }else{
+ zUrl = db_get("last-sync-url",0);
+ if( zUrl==0 ){
+ fossil_fatal("no \"remote\" repository defined");
+ }else{
+ isDefaultUrl = 1;
+ }
+ }
+ url_parse(zUrl, urlFlags);
+ if( g.url.isFile || g.url.isSsh ){
+ fossil_fatal("chat only works for http:// and https:// URLs");
+ }
+ i = (int)strlen(g.url.path);
+ while( i>0 && g.url.path[i-1]=='/' ) i--;
+ if( g.url.port==g.url.dfltPort ){
+ zUrl = mprintf(
+ "%s://%T%.*T",
+ g.url.protocol, g.url.name, i, g.url.path
+ );
+ }else{
+ zUrl = mprintf(
+ "%s://%T:%d%.*T",
+ g.url.protocol, g.url.name, g.url.port, i, g.url.path
+ );
+ }
+ if( g.argc==2 ){
+ const char *zBrowser = fossil_web_browser();
+ char *zCmd;
+ verify_all_options();
+ if( zBrowser==0 ) return;
+#ifdef _WIN32
+ zCmd = mprintf("%s %s/chat?cli &", zBrowser, zUrl);
+#else
+ zCmd = mprintf("%s \"%s/chat?cli\" &", zBrowser, zUrl);
+#endif
+ fossil_system(zCmd);
+ }else if( strcmp(g.argv[2],"send")==0 ){
+ const char *zFilename = find_option("file","f",1);
+ const char *zAs = find_option("as",0,1);
+ const char *zMsg = find_option("message","m",1);
+ int allowUnsafe = find_option("unsafe",0,0)!=0;
+ const int mFlags = HTTP_GENERIC | HTTP_QUIET | HTTP_NOCOMPRESS;
+ int i;
+ const char *zPw;
+ char *zLMTime;
+ Blob up, down, fcontent;
+ char zBoundary[80];
+ sqlite3_uint64 r[3];
+ if( zFilename==0 && zMsg==0 ){
+ fossil_fatal("must have --message or --file or both");
+ }
+ if( !g.url.isHttps && !allowUnsafe ){
+ fossil_fatal("URL \"%s\" is unencrypted. Use https:// instead", zUrl);
+ }
+ verify_all_options();
+ if( g.argc>3 ){
+ fossil_fatal("unknown extra argument: \"%s\"", g.argv[3]);
+ }
+ i = (int)strlen(g.url.path);
+ while( i>0 && g.url.path[i-1]=='/' ) i--;
+ g.url.path = mprintf("%.*s/chat-send", i, g.url.path);
+ blob_init(&up, 0, 0);
+ blob_init(&down, 0, 0);
+ sqlite3_randomness(sizeof(r),r);
+ sqlite3_snprintf(sizeof(zBoundary),zBoundary,
+ "--------%016llu%016llu%016llu", r[0], r[1], r[2]);
+ blob_appendf(&up, "%s", zBoundary);
+ zLMTime = db_text(0,
+ "SELECT strftime('%%Y-%%m-%%dT%%H:%%M:%%S','now','localtime')");
+ if( zLMTime ){
+ blob_appendf(&up,"\r\nContent-Disposition: form-data; name=\"lmtime\"\r\n"
+ "\r\n%z\r\n%s", zLMTime, zBoundary);
+ }
+ if( g.url.user && g.url.user[0] ){
+ blob_appendf(&up,"\r\nContent-Disposition: form-data; name=\"resid\"\r\n"
+ "\r\n%z\r\n%s", obscure(g.url.user), zBoundary);
+ }
+ zPw = g.url.passwd;
+ if( zPw==0 && isDefaultUrl ) zPw = unobscure(db_get("last-sync-pw", 0));
+ if( zPw && zPw[0] ){
+ blob_appendf(&up,"\r\nContent-Disposition: form-data; name=\"token\"\r\n"
+ "\r\n%z\r\n%s", obscure(zPw), zBoundary);
+ }
+ if( zMsg && zMsg[0] ){
+ blob_appendf(&up,"\r\nContent-Disposition: form-data; name=\"msg\"\r\n"
+ "\r\n%s\r\n%s", zMsg, zBoundary);
+ }
+ if( zFilename && blob_read_from_file(&fcontent, zFilename, ExtFILE)>0 ){
+ char *zFN = fossil_strdup(file_tail(zAs ? zAs : zFilename));
+ int i;
+ const char *zMime = mimetype_from_name(zFN);
+ for(i=0; zFN[i]; i++){
+ char c = zFN[i];
+ if( fossil_isalnum(c) ) continue;
+ if( c=='.' ) continue;
+ if( c=='-' ) continue;
+ zFN[i] = '_';
+ }
+ blob_appendf(&up,"\r\nContent-Disposition: form-data; name=\"file\";"
+ " filename=\"%s\"\r\n", zFN);
+ blob_appendf(&up,"Content-Type: %s\r\n\r\n", zMime);
+ blob_append(&up, fcontent.aData, fcontent.nUsed);
+ blob_appendf(&up,"\r\n%s", zBoundary);
+ }
+ blob_append(&up,"--\r\n", 4);
+ http_exchange(&up, &down, mFlags, 4, "multipart/form-data");
+ blob_reset(&up);
+ if( sqlite3_strglob("{\"isError\": true,*", blob_str(&down))==0 ){
+ if( strstr(blob_str(&down), "not logged in")!=0 ){
+ fossil_print("ERROR: username and/or password is incorrect\n");
+ }else{
+ fossil_print("ERROR: %s\n", blob_str(&down));
+ }
+ fossil_fatal("unable to send the chat message");
+ }
+ blob_reset(&down);
+ }else if( strcmp(g.argv[2],"pull")==0 ){
+ /* Pull the CHAT table from the default server down into the repository
+ ** here on the local side */
+ int allowUnsafe = find_option("unsafe",0,0)!=0;
+ int bDebug = find_option("debug",0,0)!=0;
+ const char *zOut = find_option("out",0,1);
+ int bAll = find_option("all",0,0)!=0;
+ int mFlags = HTTP_GENERIC | HTTP_QUIET | HTTP_NOCOMPRESS;
+ int msgid;
+ Blob reqUri; /* The REQUEST_URI: .../chat-backup?msgid=... */
+ char *zObs;
+ const char *zPw;
+ Blob up, down;
+ int nChat;
+ int rc;
+ verify_all_options();
+ chat_create_tables();
+ msgid = bAll ? 0 : db_int(0,"SELECT max(msgid) FROM chat");
+ if( !g.url.isHttps && !allowUnsafe ){
+ fossil_fatal("URL \"%s\" is unencrypted. Use https:// instead", zUrl);
+ }
+ blob_init(&reqUri, g.url.path, -1);
+ blob_appendf(&reqUri, "/chat-backup?msgid=%d", msgid);
+ if( g.url.user && g.url.user[0] ){
+ zObs = obscure(g.url.user);
+ blob_appendf(&reqUri, "&resid=%t", zObs);
+ fossil_free(zObs);
+ }
+ zPw = g.url.passwd;
+ if( zPw==0 && isDefaultUrl ){
+ zPw = unobscure(db_get("last-sync-pw", 0));
+ if( zPw==0 ){
+ /* Can happen if "remember password" is not used. */
+ g.url.flags |= URL_PROMPT_PW;
+ url_prompt_for_password();
+ zPw = g.url.passwd;
+ }
+ }
+ if( zPw && zPw[0] ){
+ zObs = obscure(zPw);
+ blob_appendf(&reqUri, "&token=%t", zObs);
+ fossil_free(zObs);
+ }
+ g.url.path = blob_str(&reqUri);
+ if( bDebug ){
+ fossil_print("REQUEST_URI: %s\n", g.url.path);
+ mFlags &= ~HTTP_QUIET;
+ mFlags |= HTTP_VERBOSE;
+ }
+ blob_init(&up, 0, 0);
+ blob_init(&down, 0, 0);
+ http_exchange(&up, &down, mFlags, 4, 0);
+ if( zOut ){
+ blob_write_to_file(&down, zOut);
+ fossil_print("Chat database at %s is %d bytes\n", zOut, blob_size(&down));
+ }else{
+ db_multi_exec("ATTACH ':memory:' AS chatbu;");
+ if( g.fSqlTrace ){
+ fossil_trace("-- deserialize(\"chatbu\", pData, %d);\n",
+ blob_size(&down));
+ }
+ rc = sqlite3_deserialize(g.db, "chatbu",
+ (unsigned char*)blob_buffer(&down),
+ blob_size(&down), blob_size(&down), 0);
+ if( rc ){
+ fossil_fatal("cannot open patch database: %s", sqlite3_errmsg(g.db));
+ }
+ nChat = db_int(0, "SELECT count(*) FROM chatbu.chat");
+ fossil_print("Got %d new records, %d bytes\n", nChat, blob_size(&down));
+ db_multi_exec(
+ "REPLACE INTO repository.chat(msgid,mtime,lmtime,xfrom,xmsg,"
+ "fname,fmime,mdel,file)"
+ " SELECT msgid,mtime,lmtime,xfrom,xmsg,fname,fmime,mdel,file"
+ " FROM chatbu.chat;"
+ );
+ }
+ }else if( strcmp(g.argv[2],"url")==0 ){
+ /* Show the URL to access chat. */
+ fossil_print("%s/chat\n", zUrl);
+ }else if( strcmp(g.argv[2],"purge")==0 ){
+ /* clear out expired chat messages: chat messages that are older then
+ ** chat-keep-days and that are not one or the most recent chat-keep-count
+ ** messages. */
+ chat_create_tables();
+ chat_purge();
+ }else if( strcmp(g.argv[2],"reindex")==0 ){
+ /* Rebuild the FTS5 index on chat content */
+ chat_rebuild_index(1);
+ }else{
+ fossil_fatal("no such subcommand \"%s\". Use --help for help", g.argv[2]);
+ }
+}