/* ** 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" /* 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. */ /* ** WEBPAGE: chat ** ** 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 one. ** ** 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){ int iPingTcp; login_check_credentials(); if( !g.perm.Chat ){ login_needed(g.anon.Chat); return; } iPingTcp = atoi(PD("ping","0")); if( iPingTcp<1000 || iPingTcp>65535 ) iPingTcp = 0; if( iPingTcp ) style_disable_csp(); style_set_current_feature("chat"); style_header("Chat"); @
@
@
@ @ @ @ @
@
@
@
@ Select a file to upload, drag/drop a file into this spot, @ or paste an image from the clipboard if supported by @ your environment. @
@ @
@
@
@
@
/* New chat messages get inserted immediately after this element */ @
@ @
builtin_fossil_js_bundle_or("popupwidget", "storage", NULL); /* Always in-line the javascript for the chat page */ @ 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 @ xfrom TEXT, -- Login of the sender @ xmsg TEXT, -- Raw, unformatted text of the message @ file BLOB, -- Text of the uploaded file, or NULL @ 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 @ ); ; /* ** Make sure the repository data tables used by chat exist. Create them ** if they do not. */ static 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","mdel") ){ db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT"); } } /* ** 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_prepare(&s, "DELETE FROM chat WHERE mtimei+7 ){ char c = zSafe[k]; if( !fossil_isalnum(zSafe[k-1]) && zSafe[k-1]!='/' ){ k--; c = zSafe[k]; } if( j0 ? atoi(PD("n","0")) : 0; Blob sql = empty_blob; Stmt q1; login_check_credentials(); if( !g.perm.Chat ) return; chat_create_tables(); cgi_set_content_type("text/json"); dataVersion = db_int64(0, "PRAGMA data_version"); blob_append_sql(&sql, "SELECT msgid, datetime(mtime), xfrom, xmsg, length(file)," " fname, fmime, %s" " 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 = 0; while( db_step(&q1)==SQLITE_ROW ){ int id = db_column_int(&q1, 0); const char *zDate = db_column_text(&q1, 1); const char *zFrom = db_column_text(&q1, 2); const char *zRawMsg = db_column_text(&q1, 3); int nByte = db_column_int(&q1, 4); const char *zFName = db_column_text(&q1, 5); const char *zFMime = db_column_text(&q1, 6); int iToDel = db_column_int(&q1, 7); char *zMsg; if(cnt++){ blob_append(&json, ",\n", 2); } blob_appendf(&json, "{\"msgid\":%d,", id); blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11); blob_appendf(&json, "\"xfrom\":%!j,", zFrom); blob_appendf(&json, "\"uclr\":%!j,", hash_color(zFrom)); zMsg = chat_format_to_html(zRawMsg ? zRawMsg : ""); blob_appendf(&json, "\"xmsg\":%!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); } if( iToDel ){ blob_appendf(&json, ",\"mdel\":%d}", iToDel); }else{ blob_append(&json, "}", 1); } } db_reset(&q1); 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-download ** ** 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 indicate to the browser to use ** the indicated name when saving the file. */ void chat_download_webpage(void){ int msgid; Blob r; const char *zMime; login_check_credentials(); if( !g.perm.Chat ){ style_header("Chat Not Authorized"); @

Not Authorized

@

You do not have permission to use the chatroom on this @ repository.

style_finish_page(); return; } chat_create_tables(); msgid = atoi(PD("name","0")); 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); cgi_set_content_type(zMime); cgi_set_content(&r); } /* ** WEBPAGE: chat-delete ** ** 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( "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-ping ** ** HTTP requests coming to this page from a loopback IP address cause ** a single \007 (bel) character to be written on the controlling TTY. ** This is used to implement an audiable alert by local web clients. */ void chat_ping_webpage(void){ const char *zIpAddr = PD("REMOTE_ADDR","nil"); if( cgi_is_loopback(zIpAddr) ){ cgi_append_header("Access-Control-Allow-Origin: *\r\n"); fputc(7, stderr); } } /* ** COMMAND: chat ** ** Usage: %fossil chat ?URL? ** ** Bring up a window to the chatroom feature of the Fossil repository ** at URL. Or if URL is not specified, use the default remote repository. ** Event notifications on this session cause the U+0007 character to ** be sent to the TTY on which the "fossil chat" command is run, thus ** causing an auditory notification. */ void chat_command(void){ const char *zUrl = 0; size_t i; char *azArgv[5]; db_find_and_open_repository(0,0); if( g.argc==3 ){ zUrl = g.argv[2]; }else if( g.argc!=2 ){ usage("?URL?"); }else{ zUrl = db_get("last-sync-url",0); if( zUrl==0 ){ fossil_fatal("no \"remote\" repository defined. Use a URL argument"); } url_parse(zUrl, 0); if( g.url.port==g.url.dfltPort ){ zUrl = mprintf( "%s://%T%T", g.url.protocol, g.url.name, g.url.path ); }else{ zUrl = mprintf( "%s://%T:%d%T", g.url.protocol, g.url.name, g.url.port, g.url.path ); } } if( strncmp(zUrl,"http://",7)!=0 && strncmp("https://",zUrl,8)!=0 ){ fossil_fatal("Not a valid URL: %s", zUrl); } azArgv[0] = g.argv[0]; azArgv[1] = "ui"; azArgv[2] = "--internal-chat-url"; i = strlen(zUrl); if( i && zUrl[i-1]=='/' ) i--; azArgv[3] = mprintf("%.*s/chat?ping=%%d", i, zUrl); azArgv[4] = 0; g.argv = azArgv; g.argc = 4; cmd_webserver(); }