Fossil SCM

fossil-scm / src / chat.c
Blame History Raw 1480 lines
1
/*
2
** Copyright (c) 2020 D. Richard Hipp
3
**
4
** This program is free software; you can redistribute it and/or
5
** modify it under the terms of the Simplified BSD License (also
6
** known as the "2-Clause License" or "FreeBSD License".)
7
**
8
** This program is distributed in the hope that it will be useful,
9
** but without any warranty; without even the implied warranty of
10
** merchantability or fitness for a particular purpose.
11
**
12
** Author contact information:
13
** [email protected]
14
** http://www.hwaci.com/drh/
15
**
16
*******************************************************************************
17
**
18
** This file contains code used to implement the Fossil chatroom.
19
**
20
** Initial design goals:
21
**
22
** * Keep it simple. This chatroom is not intended as a competitor
23
** or replacement for IRC, Discord, Telegram, Slack, etc. The goal
24
** is zero- or near-zero-configuration, not an abundance of features.
25
**
26
** * Intended as a place for insiders to have ephemeral conversations
27
** about a project. This is not a public gather place. Think
28
** "boardroom", not "corner pub".
29
**
30
** * One chatroom per repository.
31
**
32
** * Chat content lives in a single repository. It is never synced.
33
** Content expires and is deleted after a set interval (a week or so).
34
**
35
** Notification is accomplished using the "hanging GET" or "long poll" design
36
** in which a GET request is issued but the server does not send a reply until
37
** new content arrives. Newer Web Sockets and Server Sent Event protocols are
38
** more elegant, but are not compatible with CGI, and would thus complicate
39
** configuration.
40
*/
41
#include "config.h"
42
#include <assert.h>
43
#include "chat.h"
44
45
/*
46
** Outputs JS code to initialize a list of chat alert audio files for
47
** use by the chat front-end client. A handful of builtin files
48
** (from alerts/\*.wav) and all unversioned files matching
49
** alert-sounds/\*.{mp3,ogg,wav} are included.
50
*/
51
static void chat_emit_alert_list(void){
52
unsigned int i;
53
const char * azBuiltins[] = {
54
"builtin/alerts/plunk.wav",
55
"builtin/alerts/bflat2.wav",
56
"builtin/alerts/bflat3.wav",
57
"builtin/alerts/bloop.wav"
58
};
59
CX("window.fossil.config.chat.alerts = [\n");
60
for(i=0; i < sizeof(azBuiltins)/sizeof(azBuiltins[0]); ++i){
61
CX("%s%!j", i ? ", " : "", azBuiltins[i]);
62
}
63
if( db_table_exists("repository","unversioned") ){
64
Stmt q = empty_Stmt;
65
db_prepare(&q, "SELECT 'uv/'||name FROM unversioned "
66
"WHERE content IS NOT NULL "
67
"AND (name LIKE 'alert-sounds/%%.wav' "
68
"OR name LIKE 'alert-sounds/%%.mp3' "
69
"OR name LIKE 'alert-sounds/%%.ogg')");
70
while(SQLITE_ROW==db_step(&q)){
71
CX(", %!j", db_column_text(&q, 0));
72
}
73
db_finalize(&q);
74
}
75
CX("\n];\n");
76
}
77
78
/* Settings that can be used to control chat */
79
/*
80
** SETTING: chat-initial-history width=10 default=50
81
**
82
** If this setting has an integer value of N, then when /chat first
83
** starts up it initializes the screen with the N most recent chat
84
** messages. If N is zero, then all chat messages are loaded.
85
*/
86
/*
87
** SETTING: chat-keep-count width=10 default=50
88
**
89
** When /chat is cleaning up older messages, it will always keep
90
** the most recent chat-keep-count messages, even if some of those
91
** messages are older than the discard threshold. If this value
92
** is zero, then /chat is free to delete all historic messages once
93
** they are old enough.
94
*/
95
/*
96
** SETTING: chat-keep-days width=10 default=7
97
**
98
** The /chat subsystem will try to discard messages that are older then
99
** chat-keep-days. The value of chat-keep-days can be a floating point
100
** number. So, for example, if you only want to keep chat messages for
101
** 12 hours, set this value to 0.5.
102
**
103
** A value of 0.0 or less means that messages are retained forever.
104
*/
105
/*
106
** SETTING: chat-inline-images boolean default=on
107
**
108
** Specifies whether posted images in /chat should default to being
109
** displayed inline or as downloadable links. Each chat user can
110
** change this value for their current chat session in the UI.
111
*/
112
/*
113
** SETTING: chat-poll-timeout width=10 default=420
114
**
115
** On an HTTP request to /chat-poll, if there is no new content available,
116
** the reply is delayed waiting for new content to arrive. (This is the
117
** "long poll" strategy of event delivery to the client.) This setting
118
** determines approximately how long /chat-poll will delay before giving
119
** up and returning an empty reply. The default value is about 7 minutes,
120
** which works well for Fossil behind the althttpd web server. Other
121
** server environments may choose a longer or shorter delay.
122
**
123
** For maximum efficiency, it is best to choose the longest delay that
124
** does not cause timeouts in intermediate proxies or web server.
125
*/
126
/*
127
** SETTING: chat-alert-sound width=10
128
**
129
** This is the name of the builtin sound file to use for the alert tone.
130
** The value must be the name of a builtin WAV file.
131
*/
132
/*
133
** SETTING: chat-timeline-user width=10
134
**
135
** If this setting is defined and is not an empty string, then
136
** timeline events are posted to the chat as they arrive. The synthesized
137
** chat messages appear to come from the user identified by this setting,
138
** not the user on the timeline event.
139
**
140
** All chat messages that come from the chat-timeline-user are
141
** interpreted as text/x-fossil-wiki instead of as text/x-markdown.
142
** For this reason, the chat-timeline-user name should probably not be
143
** a real user.
144
*/
145
/*
146
** WEBPAGE: chat loadavg-exempt
147
**
148
** Start up a browser-based chat session.
149
**
150
** This is the main page that humans use to access the chatroom. Simply
151
** point a web-browser at /chat and the screen fills with the latest
152
** chat messages, and waits for new ones.
153
**
154
** Other /chat-OP pages are used by XHR requests from this page to
155
** send new chat message, delete older messages, or poll for changes.
156
*/
157
void chat_webpage(void){
158
char *zAlert;
159
char *zProjectName;
160
char * zInputPlaceholder0; /* Common text input placeholder value */
161
162
login_check_credentials();
163
if( !g.perm.Chat ){
164
login_needed(g.anon.Chat);
165
return;
166
}
167
zAlert = mprintf("%s/builtin/%s", g.zBaseURL,
168
db_get("chat-alert-sound","alerts/plunk.wav"));
169
zProjectName = db_get("project-name","Unnamed project");
170
zInputPlaceholder0 =
171
mprintf("Type markdown-formatted message for %h.", zProjectName);
172
style_set_current_feature("chat");
173
style_header("Chat");
174
@ <div id='chat-input-area'>
175
@ <div id='chat-input-line-wrapper' class='compact'>
176
@ <input type="text" id="chat-input-field-single" \
177
@ data-placeholder0="%h(zInputPlaceholder0)" \
178
@ data-placeholder="%h(zInputPlaceholder0)" \
179
@ class="chat-input-field"></input>
180
@ <textarea id="chat-input-field-multi" \
181
@ data-placeholder0="%h(zInputPlaceholder0)" \
182
@ data-placeholder="%h(zInputPlaceholder0)" \
183
@ class="chat-input-field hidden"></textarea>
184
@ <div contenteditable id="chat-input-field-x" \
185
@ data-placeholder0="%h(zInputPlaceholder0)" \
186
@ data-placeholder="%h(zInputPlaceholder0)" \
187
@ class="chat-input-field hidden"></div>
188
@ <div id='chat-buttons-wrapper'>
189
@ <span class='cbutton' id="chat-button-preview" \
190
@ title="Preview message (Shift-Enter)">&#128065;</span>
191
@ <span class='cbutton' id="chat-button-search" \
192
@ title="Search chat history">&#x1f50d;</span>
193
@ <span class='cbutton' id="chat-button-attach" \
194
@ title="Attach file to message">&#x1f4ce;</span>
195
@ <span class='cbutton' id="chat-button-settings" \
196
@ title="Configure chat">&#9881;</span>
197
@ <span class='cbutton' id="chat-button-submit" \
198
@ title="Send message (Ctrl-Enter)">&#128228;</span>
199
@ </div>
200
@ </div>
201
@ <div id='chat-input-file-area'>
202
@ <div class='file-selection-wrapper hidden'>
203
@ <input type="file" name="file" id="chat-input-file">
204
@ </div>
205
@ <div id="chat-drop-details"></div>
206
@ </div>
207
@ </div>
208
@ <div id='chat-user-list-wrapper' class='hidden'>
209
@ <div class='legend'>
210
@ <span class='help-buttonlet'>
211
@ Users who have messages in the currently-loaded list.<br><br>
212
@ <strong>Tap a user name</strong> to filter messages
213
@ on that user and tap again to clear the filter.<br><br>
214
@ <strong>Tap the title</strong> of this widget to toggle
215
@ the list on and off.
216
@ </span>
217
@ <span>Active users (sorted by last message time)</span>
218
@ </div>
219
@ <div id='chat-user-list'></div>
220
@ </div>
221
@ <button id='chat-clear-filter' class='hidden'>Clear filter</button>
222
@ <div id='chat-preview' class='hidden chat-view'>
223
@ <header>Preview: (<a href='%R/md_rules' target='_blank'>markdown reference</a>)</header>
224
@ <div id='chat-preview-content'></div>
225
@ <div class='button-bar'><button class='action-close'>Close Preview</button></div>
226
@ </div>
227
@ <div id='chat-config' class='hidden chat-view'>
228
@ <div id='chat-config-options'></div>
229
/* ^^^populated client-side */
230
@ <div class='button-bar'><button class='action-close'>Close Settings</button></div>
231
@ </div>
232
@ <div id='chat-search' class='hidden chat-view'>
233
@ <div id='chat-search-content'></div>
234
/* ^^^populated client-side */
235
@ <div class='button-bar'>
236
@ <button class='action-clear'>Clear results</button>
237
@ <button class='action-close'>Close Search</button>
238
@ </div>
239
@ </div>
240
@ <div id='chat-messages-wrapper' class='chat-view'>
241
/* New chat messages get inserted immediately after this element */
242
@ <span id='message-inject-point'></span>
243
@ </div>
244
@ <div id='chat-zoom' class='hidden chat-view'>
245
@ <div id='chat-zoom-content'></div>
246
@ <div class='button-bar'><button class='action-close'>Close Zoom</button></div>
247
@ </div>
248
@ <span id='chat-zoom-marker' class='hidden'><!-- placeholder marker for zoomed msg --></span>
249
fossil_free(zProjectName);
250
fossil_free(zInputPlaceholder0);
251
builtin_fossil_js_bundle_or("popupwidget", "storage", "fetch",
252
"pikchr", "confirmer", "copybutton",
253
NULL);
254
/* Always in-line the javascript for the chat page */
255
@ <script nonce="%h(style_nonce())">/* chat.c:%d(__LINE__) */
256
/* We need an onload handler to ensure that window.fossil is
257
initialized before the chat init code runs. */
258
@ window.addEventListener('load', function(){
259
@ document.body.classList.add('chat');
260
@ /*^^^for skins which add their own BODY tag */;
261
@ window.fossil.config.chat = {
262
@ fromcli: %h(PB("cli")?"true":"false"),
263
@ alertSound: "%h(zAlert)",
264
@ initSize: %d(db_get_int("chat-initial-history",50)),
265
@ imagesInline: !!%d(db_get_boolean("chat-inline-images",1)),
266
@ pollTimeout: %d(db_get_int("chat-poll-timeout",420))
267
@ };
268
ajax_emit_js_preview_modes(0);
269
chat_emit_alert_list();
270
@ }, false);
271
@ </script>
272
builtin_request_js("fossil.page.chat.js");
273
style_finish_page();
274
}
275
276
/*
277
** Definition of repository tables used by chat
278
*/
279
static const char zChatSchema1[] =
280
@ CREATE TABLE repository.chat(
281
@ msgid INTEGER PRIMARY KEY AUTOINCREMENT,
282
@ mtime JULIANDAY, -- Time for this entry - Julianday Zulu
283
@ lmtime TEXT, -- Client YYYY-MM-DDZHH:MM:SS when message originally sent
284
@ xfrom TEXT, -- Login of the sender
285
@ xmsg TEXT, -- Raw, unformatted text of the message
286
@ fname TEXT, -- Filename of the uploaded file, or NULL
287
@ fmime TEXT, -- MIMEType of the upload file, or NULL
288
@ mdel INT, -- msgid of another message to delete
289
@ file BLOB -- Text of the uploaded file, or NULL
290
@ );
291
;
292
293
294
/*
295
** Create or rebuild the /chat search index. Requires that the
296
** repository.chat table exists. If bForce is true, it will drop the
297
** chatfts1 table and recreate/reindex it. If bForce is 0, it will
298
** only index the chat content if the chatfts1 table does not already
299
** exist.
300
*/
301
void chat_rebuild_index(int bForce){
302
if( !db_table_exists("repository","chat") ) return;
303
if( bForce!=0 ){
304
db_multi_exec("DROP TABLE IF EXISTS chatfts1");
305
}
306
if( bForce!=0 || !db_table_exists("repository", "chatfts1") ){
307
const int tokType = search_tokenizer_type(0);
308
const char *zTokenizer = search_tokenize_arg_for_type(
309
tokType==FTS5TOK_NONE ? FTS5TOK_PORTER : tokType
310
/* Special case: if fts search is disabled for the main repo
311
** content, use a default tokenizer here. */
312
);
313
assert( zTokenizer && zTokenizer[0] );
314
db_multi_exec(
315
"CREATE VIRTUAL TABLE repository.chatfts1 USING fts5("
316
" xmsg, content=chat, content_rowid=msgid%s"
317
");"
318
"INSERT INTO repository.chatfts1(chatfts1) VALUES('rebuild');",
319
zTokenizer/*safe-for-%s*/
320
);
321
}
322
}
323
324
/*
325
** Make sure the repository data tables used by chat exist. Create
326
** them if they do not. Set up TEMP triggers (if needed) to update the
327
** chatfts1 table as the chat table is updated.
328
*/
329
void chat_create_tables(void){
330
if( !db_table_exists("repository","chat") ){
331
db_multi_exec(zChatSchema1/*works-like:""*/);
332
}else if( !db_table_has_column("repository","chat","lmtime") ){
333
if( !db_table_has_column("repository","chat","mdel") ){
334
db_multi_exec("ALTER TABLE chat ADD COLUMN mdel INT");
335
}
336
db_multi_exec("ALTER TABLE chat ADD COLUMN lmtime TEXT");
337
}
338
chat_rebuild_index(0);
339
db_multi_exec(
340
"CREATE TEMP TRIGGER IF NOT EXISTS chat_ai AFTER INSERT ON chat BEGIN "
341
" INSERT INTO chatfts1(rowid, xmsg) VALUES(new.msgid, new.xmsg);"
342
"END;"
343
"CREATE TEMP TRIGGER IF NOT EXISTS chat_ad AFTER DELETE ON chat BEGIN "
344
" INSERT INTO chatfts1(chatfts1, rowid, xmsg) "
345
" VALUES('delete', old.msgid, old.xmsg);"
346
"END;"
347
);
348
}
349
350
/*
351
** Delete old content from the chat table.
352
*/
353
static void chat_purge(void){
354
int mxCnt = db_get_int("chat-keep-count",50);
355
double mxDays = atof(db_get("chat-keep-days","7"));
356
double rAge;
357
int msgid;
358
rAge = db_double(0.0, "SELECT julianday('now')-mtime FROM chat"
359
" ORDER BY msgid LIMIT 1");
360
if( rAge>mxDays ){
361
msgid = db_int(0, "SELECT msgid FROM chat"
362
" ORDER BY msgid DESC LIMIT 1 OFFSET %d", mxCnt);
363
if( msgid>0 ){
364
Stmt s;
365
db_multi_exec("PRAGMA secure_delete=ON;");
366
db_prepare(&s,
367
"DELETE FROM chat WHERE mtime<julianday('now')-:mxage"
368
" AND msgid<%d", msgid);
369
db_bind_double(&s, ":mxage", mxDays);
370
db_step(&s);
371
db_finalize(&s);
372
}
373
}
374
}
375
376
/*
377
** Sets the current CGI response type to application/json then emits a
378
** JSON-format error message object. If fAsMessageList is true then
379
** the object is output using the list format described for chat-poll,
380
** else it is emitted as a single object in that same format.
381
*/
382
static void chat_emit_permissions_error(int fAsMessageList){
383
char * zTime = cgi_iso8601_datestamp();
384
cgi_set_content_type("application/json");
385
if(fAsMessageList){
386
CX("{\"msgs\":[{");
387
}else{
388
CX("{");
389
}
390
CX("\"isError\": true, \"xfrom\": null,");
391
CX("\"mtime\": %!j, \"lmtime\": %!j,", zTime, zTime);
392
CX("\"xmsg\": \"Missing permissions or not logged in. "
393
"Try <a href='%R/login?g=chat'>logging in</a>.\"");
394
if(fAsMessageList){
395
CX("}]}");
396
}else{
397
CX("}");
398
}
399
fossil_free(zTime);
400
}
401
402
/*
403
** Like chat_emit_permissions_error() but emits a single
404
** /chat-message-format JSON object about a CSRF violation.
405
*/
406
static void chat_emit_csrf_error(void){
407
char * zTime = cgi_iso8601_datestamp();
408
cgi_set_content_type("application/json");
409
CX("{");
410
CX("\"isError\": true, \"xfrom\": null,");
411
CX("\"mtime\": %!j, \"lmtime\": %!j,", zTime, zTime);
412
CX("\"xmsg\": \"CSRF validation failure.\"");
413
CX("}");
414
fossil_free(zTime);
415
}
416
417
/*
418
** WEBPAGE: chat-send hidden loadavg-exempt
419
**
420
** This page receives (via XHR) a new chat-message and/or a new file
421
** to be entered into the chat history.
422
**
423
** On success it responds with an empty response: the new message
424
** should be fetched via /chat-poll. On error, e.g. login expiry,
425
** it emits a JSON response in the same form as described for
426
** /chat-poll errors, but as a standalone object instead of a
427
** list of objects.
428
**
429
** Requests to this page should be POST, not GET. POST parameters
430
** include:
431
**
432
** msg The (Markdown) text of the message to be sent
433
**
434
** file The content of the file attachment
435
**
436
** lmtime ISO-8601 formatted date-time string showing the local time
437
** of the sender.
438
**
439
** At least one of the "msg" or "file" POST parameters must be provided.
440
*/
441
void chat_send_webpage(void){
442
int nByte;
443
const char *zMsg;
444
const char *zUserName;
445
login_check_credentials();
446
if( 0==g.perm.Chat ) {
447
chat_emit_permissions_error(0);
448
return;
449
}else if( g.eAuthMethod==AUTH_COOKIE && 0==cgi_csrf_safe(1) ){
450
chat_emit_csrf_error();
451
return;
452
}
453
zUserName = (g.zLogin && g.zLogin[0]) ? g.zLogin : "nobody";
454
nByte = atoi(PD("file:bytes","0"));
455
zMsg = PD("msg","");
456
db_begin_write();
457
db_unprotect(PROTECT_READONLY);
458
chat_create_tables();
459
chat_purge();
460
if( nByte==0 ){
461
if( zMsg[0] ){
462
db_multi_exec(
463
"INSERT INTO chat(mtime,lmtime,xfrom,xmsg)"
464
"VALUES(julianday('now'),%Q,%Q,%Q)",
465
P("lmtime"), zUserName, zMsg
466
);
467
}
468
}else{
469
Stmt q;
470
Blob b;
471
db_prepare(&q,
472
"INSERT INTO chat(mtime,lmtime,xfrom,xmsg,file,fname,fmime)"
473
"VALUES(julianday('now'),%Q,%Q,%Q,:file,%Q,%Q)",
474
P("lmtime"), zUserName, zMsg, PD("file:filename",""),
475
PD("file:mimetype","application/octet-stream"));
476
blob_init(&b, P("file"), nByte);
477
db_bind_blob(&q, ":file", &b);
478
db_step(&q);
479
db_finalize(&q);
480
blob_reset(&b);
481
}
482
db_commit_transaction();
483
db_protect_pop();
484
}
485
486
/*
487
** This routine receives raw (user-entered) message text and
488
** transforms it into HTML that is safe to insert using innerHTML. As
489
** of 2021-09-19, it does so by using wiki_convert() or
490
** markdown_to_html() to convert wiki/markdown-formatted zMsg to HTML.
491
**
492
** Space to hold the returned string is obtained from fossil_malloc()
493
** and must be freed by the caller.
494
*/
495
static char *chat_format_to_html(const char *zMsg, int isWiki){
496
Blob out;
497
blob_init(&out, "", 0);
498
if( zMsg==0 || zMsg[0]==0 ){
499
/* No-op */
500
}else if( isWiki ){
501
/* Used for chat-timeline-user. The zMsg is text/x-fossil-wiki. */
502
Blob bIn;
503
blob_init(&bIn, zMsg, (int)strlen(zMsg));
504
wiki_convert(&bIn, &out, WIKI_INLINE);
505
}else{
506
/* The common case: zMsg is text/x-markdown */
507
Blob bIn;
508
blob_init(&bIn, zMsg, (int)strlen(zMsg));
509
markdown_to_html(&bIn, NULL, &out);
510
}
511
return blob_str(&out);
512
}
513
514
/*
515
** COMMAND: test-chat-formatter
516
**
517
** Usage: %fossil test-chat-formatter ?OPTIONS? STRING ...
518
**
519
** Transform each argument string into HTML that will display the
520
** chat message. This is used to test the formatter and to verify
521
** that a malicious message text will not cause HTML or JS injection
522
** into the chat display in a browser.
523
**
524
** Options:
525
**
526
** -w|--wiki Assume fossil wiki format instead of markdown
527
*/
528
void chat_test_formatter_cmd(void){
529
int i;
530
char *zOut;
531
int const isWiki = find_option("w","wiki",0)!=0;
532
db_find_and_open_repository(0,0);
533
g.perm.Hyperlink = 1;
534
for(i=2; i<g.argc; i++){
535
zOut = chat_format_to_html(g.argv[i], isWiki);
536
fossil_print("[%d]: %s\n", i-1, zOut);
537
fossil_free(zOut);
538
}
539
}
540
541
/*
542
** The SQL statement passed as the first argument should return zero or
543
** more rows of data, each of which represents a single message from the
544
** "chat" table. The rows returned should be similar to those returned
545
** by:
546
**
547
** SELECT msgid,
548
** datetime(mtime),
549
** xfrom,
550
** xmsg,
551
** octet_length(file),"
552
** fname,
553
** fmime,
554
** mdel,
555
** lmtime
556
** FROM chat;
557
**
558
** This function loops through all rows returned by statement p, adding
559
** a record to the JSON stored in argument pJson for each. See comments
560
** above function chat_poll_webpage() for a description of the JSON records
561
** added to pJson.
562
*/
563
static int chat_poll_rowstojson(
564
Stmt *p, /* Statement to read rows from */
565
int bRaw, /* True to return raw format xmsg */
566
Blob *pJson /* Append json array entries here */
567
){
568
int cnt = 0;
569
const char *zChatUser = db_get("chat-timeline-user",0);
570
while( db_step(p)==SQLITE_ROW ){
571
int isWiki = 0; /* True if chat message is x-fossil-wiki */
572
int id = db_column_int(p, 0);
573
const char *zDate = db_column_text(p, 1);
574
const char *zFrom = db_column_text(p, 2);
575
const char *zRawMsg = db_column_text(p, 3);
576
int nByte = db_column_int(p, 4);
577
const char *zFName = db_column_text(p, 5);
578
const char *zFMime = db_column_text(p, 6);
579
int iToDel = db_column_int(p, 7);
580
const char *zLMtime = db_column_text(p, 8);
581
char *zMsg;
582
if(cnt++){
583
blob_append(pJson, ",\n", 2);
584
}
585
blob_appendf(pJson, "{\"msgid\":%d,", id);
586
blob_appendf(pJson, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
587
if( zLMtime && zLMtime[0] ){
588
blob_appendf(pJson, "\"lmtime\":%!j,", zLMtime);
589
}
590
blob_append(pJson, "\"xfrom\":", -1);
591
if(zFrom){
592
blob_appendf(pJson, "%!j,", zFrom);
593
isWiki = fossil_strcmp(zFrom,zChatUser)==0;
594
}else{
595
/* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
596
blob_appendf(pJson, "null,");
597
isWiki = 0;
598
}
599
blob_appendf(pJson, "\"uclr\":%!j,",
600
isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
601
602
if(bRaw){
603
blob_appendf(pJson, "\"xmsg\":%!j,", zRawMsg);
604
}else{
605
zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
606
blob_appendf(pJson, "\"xmsg\":%!j,", zMsg);
607
fossil_free(zMsg);
608
}
609
610
if( nByte==0 ){
611
blob_appendf(pJson, "\"fsize\":0");
612
}else{
613
blob_appendf(pJson, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
614
nByte, zFName, zFMime);
615
}
616
617
if( iToDel ){
618
blob_appendf(pJson, ",\"mdel\":%d}", iToDel);
619
}else{
620
blob_append(pJson, "}", 1);
621
}
622
}
623
db_reset(p);
624
625
return cnt;
626
}
627
628
/*
629
** WEBPAGE: chat-poll hidden loadavg-exempt
630
**
631
** The chat page generated by /chat using an XHR to this page to
632
** request new chat content. A typical invocation is:
633
**
634
** /chat-poll/N
635
** /chat-poll?name=N
636
**
637
** The "name" argument should begin with an integer which is the largest
638
** "msgid" that the chat page currently holds. If newer content is
639
** available, this routine returns that content straight away. If no new
640
** content is available, this webpage blocks until the new content becomes
641
** available. In this way, the system implements "hanging-GET" or "long-poll"
642
** style event notification. If no new content arrives after a delay of
643
** approximately chat-poll-timeout seconds (default: 420), then reply is
644
** sent with an empty "msg": field.
645
**
646
** If N is negative, then the return value is the N most recent messages.
647
** Hence a request like /chat-poll/-100 can be used to initialize a new
648
** chat session to just the most recent messages.
649
**
650
** Some webservers (althttpd) do not allow a term of the URL path to
651
** begin with "-". Then /chat-poll/-100 cannot be used. Instead you
652
** have to say "/chat-poll?name=-100".
653
**
654
** If the integer parameter "before" is passed in, it is assumed that
655
** the client is requesting older messages, up to (but not including)
656
** that message ID, in which case the next-oldest "n" messages
657
** (default=chat-initial-history setting, equivalent to n=0) are
658
** returned (negative n fetches all older entries). The client then
659
** needs to take care to inject them at the end of the history rather
660
** than the same place new messages go.
661
**
662
** If "before" is provided, "name" is ignored.
663
**
664
** If "raw" is provided, the "xmsg" text is sent back as-is, in
665
** markdown format, rather than being HTML-ized. This is not used or
666
** supported by fossil's own chat client but is intended for 3rd-party
667
** clients. (Specifically, for Brad Harder's curses-based client.)
668
**
669
** The reply from this webpage is JSON that describes the new content.
670
** Format of the json:
671
**
672
** | {
673
** | "msgs":[
674
** | {
675
** | "msgid": integer // message id
676
** | "mtime": text // When sent: YYYY-MM-DDTHH:MM:SSZ
677
** | "lmtime: text // Sender's client-side YYYY-MM-DDTHH:MM:SS
678
** | "xfrom": text // Login name of sender
679
** | "uclr": text // Color string associated with the user
680
** | "xmsg": text // HTML text of the message
681
** | "fsize": integer // file attachment size in bytes
682
** | "fname": text // Name of file attachment
683
** | "fmime": text // MIME-type of file attachment
684
** | "mdel": integer // message id of prior message to delete
685
** | }
686
** | ]
687
** | }
688
**
689
** The "fname" and "fmime" fields are only present if "fsize" is greater
690
** than zero. The "xmsg" field may be an empty string if "fsize" is zero.
691
**
692
** The "msgid" values will be in increasing order.
693
**
694
** The "mdel" will only exist if "xmsg" is an empty string and "fsize" is zero.
695
**
696
** The "lmtime" value might be unknown, in which case it is omitted.
697
**
698
** The messages are ordered oldest first unless "before" is provided, in which
699
** case they are sorted newest first (to facilitate the client-side UI update).
700
**
701
** As a special case, if this routine encounters an error, e.g. the user's
702
** permissions cannot be verified because their login cookie expired, the
703
** request returns a slightly modified structure:
704
**
705
** | {
706
** | "msgs":[
707
** | {
708
** | "isError": true,
709
** | "xfrom": null,
710
** | "xmsg": "error details"
711
** | "mtime": as above,
712
** | "ltime": same as mtime
713
** | }
714
** | ]
715
** | }
716
**
717
** If the client gets such a response, it should display the message
718
** in a prominent manner and then stop polling for new messages.
719
*/
720
void chat_poll_webpage(void){
721
Blob json; /* The json to be constructed and returned */
722
sqlite3_int64 dataVersion; /* Data version. Used for polling. */
723
const int iDelay = 1000; /* Delay until next poll (milliseconds) */
724
int nDelay; /* Maximum delay.*/
725
int msgid = atoi(PD("name","0"));
726
const int msgBefore = atoi(PD("before","0"));
727
int nLimit = msgBefore>0 ? atoi(PD("n","0")) : 0;
728
const int bRaw = P("raw")!=0;
729
730
Blob sql = empty_blob;
731
Stmt q1;
732
nDelay = db_get_int("chat-poll-timeout",420); /* Default about 7 minutes */
733
login_check_credentials();
734
if( !g.perm.Chat ) {
735
chat_emit_permissions_error(1);
736
return;
737
}
738
chat_create_tables();
739
cgi_set_content_type("application/json");
740
dataVersion = db_int64(0, "PRAGMA data_version");
741
blob_append_sql(&sql,
742
"SELECT msgid, datetime(mtime), xfrom, xmsg, octet_length(file),"
743
" fname, fmime, %s, lmtime"
744
" FROM chat ",
745
msgBefore>0 ? "0 as mdel" : "mdel");
746
if( msgid<=0 || msgBefore>0 ){
747
db_begin_write();
748
chat_purge();
749
db_commit_transaction();
750
}
751
if(msgBefore>0){
752
if(0==nLimit){
753
nLimit = db_get_int("chat-initial-history",50);
754
}
755
blob_append_sql(&sql,
756
" WHERE msgid<%d"
757
" ORDER BY msgid DESC "
758
"LIMIT %d",
759
msgBefore, nLimit>0 ? nLimit : -1
760
);
761
}else{
762
if( msgid<0 ){
763
msgid = db_int(0,
764
"SELECT msgid FROM chat WHERE mdel IS NOT true"
765
" ORDER BY msgid DESC LIMIT 1 OFFSET %d", -msgid);
766
}
767
blob_append_sql(&sql,
768
" WHERE msgid>%d"
769
" ORDER BY msgid",
770
msgid
771
);
772
}
773
db_prepare(&q1, "%s", blob_sql_text(&sql));
774
blob_reset(&sql);
775
blob_init(&json, "{\"msgs\":[\n", -1);
776
while( nDelay>0 ){
777
int cnt = chat_poll_rowstojson(&q1, bRaw, &json);
778
if( cnt || msgBefore>0 ){
779
break;
780
}
781
sqlite3_sleep(iDelay); nDelay--;
782
while( nDelay>0 ){
783
sqlite3_int64 newDataVers = db_int64(0,"PRAGMA repository.data_version");
784
if( newDataVers!=dataVersion ){
785
dataVersion = newDataVers;
786
break;
787
}
788
sqlite3_sleep(iDelay); nDelay--;
789
}
790
} /* Exit by "break" */
791
db_finalize(&q1);
792
blob_append(&json, "\n]}", 3);
793
cgi_set_content(&json);
794
return;
795
}
796
797
798
/*
799
** WEBPAGE: chat-query hidden loadavg-exempt
800
*/
801
void chat_query_webpage(void){
802
Blob json; /* The json to be constructed and returned */
803
Blob sql = empty_blob;
804
Stmt q1;
805
int nLimit = atoi(PD("n","500"));
806
int iFirst = atoi(PD("i","0"));
807
const char *zQuery = PD("q", "");
808
i64 iMin = 0;
809
i64 iMax = 0;
810
811
login_check_credentials();
812
if( !g.perm.Chat ) {
813
chat_emit_permissions_error(1);
814
return;
815
}
816
chat_create_tables();
817
cgi_set_content_type("application/json");
818
819
if( zQuery[0] ){
820
iMax = db_int64(0, "SELECT max(msgid) FROM chat");
821
iMin = db_int64(0, "SELECT min(msgid) FROM chat");
822
if( '#'==zQuery[0] ){
823
/* Assume we're looking for an exact msgid match. */
824
++zQuery;
825
blob_append_sql(&sql,
826
"SELECT msgid, datetime(mtime), xfrom, "
827
" xmsg, octet_length(file), fname, fmime, mdel, lmtime "
828
" FROM chat WHERE msgid=+%Q",
829
zQuery
830
);
831
}else{
832
char * zPat = search_simplify_pattern(zQuery);
833
blob_append_sql(&sql,
834
"SELECT * FROM ("
835
"SELECT c.msgid, datetime(c.mtime), c.xfrom, "
836
" highlight(chatfts1, 0, '<span class=\"match\">', '</span>'), "
837
" octet_length(c.file), c.fname, c.fmime, c.mdel, c.lmtime "
838
" FROM chatfts1(%Q) f, chat c "
839
" WHERE f.rowid=c.msgid"
840
" ORDER BY f.rowid DESC LIMIT %d"
841
") ORDER BY 1 ASC", zPat, nLimit
842
);
843
fossil_free(zPat);
844
}
845
}else{
846
blob_append_sql(&sql,
847
"SELECT msgid, datetime(mtime), xfrom, "
848
" xmsg, octet_length(file), fname, fmime, mdel, lmtime"
849
" FROM chat WHERE msgid>=%d LIMIT %d",
850
iFirst, nLimit
851
);
852
}
853
854
db_prepare(&q1, "%s", blob_sql_text(&sql));
855
blob_reset(&sql);
856
blob_init(&json, "{\"msgs\":[\n", -1);
857
chat_poll_rowstojson(&q1, 0, &json);
858
db_finalize(&q1);
859
blob_appendf(&json, "\n], \"first\":%lld, \"last\":%lld}", iMin, iMax);
860
cgi_set_content(&json);
861
return;
862
}
863
864
/*
865
** WEBPAGE: chat-fetch-one hidden loadavg-exempt
866
**
867
** /chat-fetch-one/N
868
**
869
** Fetches a single message with the given ID, if available.
870
**
871
** Options:
872
**
873
** raw = the xmsg field will be returned unparsed.
874
**
875
** Response is either a single object in the format returned by
876
** /chat-poll (without the wrapper array) or a JSON-format error
877
** response, as documented for ajax_route_error().
878
*/
879
void chat_fetch_one(void){
880
Blob json = empty_blob; /* The json to be constructed and returned */
881
const int fRaw = PD("raw",0)!=0;
882
const int msgid = atoi(PD("name","0"));
883
const char *zChatUser;
884
int isWiki;
885
Stmt q;
886
login_check_credentials();
887
if( !g.perm.Chat ) {
888
chat_emit_permissions_error(0);
889
return;
890
}
891
zChatUser = db_get("chat-timeline-user",0);
892
chat_create_tables();
893
cgi_set_content_type("application/json");
894
db_prepare(&q,
895
"SELECT datetime(mtime), xfrom, xmsg, octet_length(file),"
896
" fname, fmime, lmtime"
897
" FROM chat WHERE msgid=%d AND mdel IS NULL",
898
msgid);
899
if(SQLITE_ROW==db_step(&q)){
900
const char *zDate = db_column_text(&q, 0);
901
const char *zFrom = db_column_text(&q, 1);
902
const char *zRawMsg = db_column_text(&q, 2);
903
const int nByte = db_column_int(&q, 3);
904
const char *zFName = db_column_text(&q, 4);
905
const char *zFMime = db_column_text(&q, 5);
906
const char *zLMtime = db_column_text(&q, 7);
907
blob_appendf(&json,"{\"msgid\": %d,", msgid);
908
909
blob_appendf(&json, "\"mtime\":\"%.10sT%sZ\",", zDate, zDate+11);
910
if( zLMtime && zLMtime[0] ){
911
blob_appendf(&json, "\"lmtime\":%!j,", zLMtime);
912
}
913
blob_append(&json, "\"xfrom\":", -1);
914
if(zFrom){
915
blob_appendf(&json, "%!j,", zFrom);
916
isWiki = fossil_strcmp(zFrom, zChatUser)==0;
917
}else{
918
/* see https://fossil-scm.org/forum/forumpost/e0be0eeb4c */
919
blob_appendf(&json, "null,");
920
isWiki = 0;
921
}
922
blob_appendf(&json, "\"uclr\":%!j,",
923
isWiki ? "transparent" : user_color(zFrom ? zFrom : "nobody"));
924
blob_append(&json,"\"xmsg\":", 7);
925
if(fRaw){
926
blob_appendf(&json, "%!j,", zRawMsg);
927
}else{
928
char * zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "", isWiki);
929
blob_appendf(&json, "%!j,", zMsg);
930
fossil_free(zMsg);
931
}
932
if( nByte==0 ){
933
blob_appendf(&json, "\"fsize\":0");
934
}else{
935
blob_appendf(&json, "\"fsize\":%d,\"fname\":%!j,\"fmime\":%!j",
936
nByte, zFName, zFMime);
937
}
938
blob_append(&json,"}",1);
939
cgi_set_content(&json);
940
}else{
941
ajax_route_error(404,"Chat message #%d not found.", msgid);
942
}
943
db_finalize(&q);
944
}
945
946
/*
947
** WEBPAGE: chat-download hidden loadavg-exempt
948
**
949
** Download the CHAT.FILE attachment associated with a single chat
950
** entry. The "name" query parameter begins with an integer that
951
** identifies the particular chat message. The integer may be followed
952
** by a / and a filename, which will (A) indicate to the browser to
953
** use the indicated name when saving the file and (B) be used to
954
** guess the mimetype in some particular cases involving the "render"
955
** flag.
956
**
957
** If the "render" URL parameter is provided, the blob has a size
958
** greater than zero, and blob meets one of the following conditions
959
** then the fossil-rendered form of that content is returned, rather
960
** than the original:
961
**
962
** - Mimetype is text/x-markdown or text/markdown: emit HTML.
963
**
964
** - Mimetype is text/x-fossil-wiki or P("name") ends with ".wiki":
965
** emit HTML.
966
**
967
** - Mimetype is text/x-pikchr or P("name") ends with ".pikchr": emit
968
** image/svg+xml if rendering succeeds or text/html if rendering
969
** fails.
970
*/
971
void chat_download_webpage(void){
972
int msgid;
973
int bCheckedMimetype = 0; /* true to bypass the text/... mimetype
974
** check at the end */
975
Blob r; /* file content */
976
const char *zMime; /* file mimetype */
977
const char *zName = PD("name","0");
978
login_check_credentials();
979
if( !g.perm.Chat ){
980
style_header("Chat Not Authorized");
981
@ <h1>Not Authorized</h1>
982
@ <p>You do not have permission to use the chatroom on this
983
@ repository.</p>
984
style_finish_page();
985
return;
986
}
987
chat_create_tables();
988
msgid = atoi(zName);
989
blob_zero(&r);
990
zMime = db_text(0, "SELECT fmime FROM chat wHERE msgid=%d", msgid);
991
if( zMime==0 ) return;
992
db_blob(&r, "SELECT file FROM chat WHERE msgid=%d", msgid);
993
if( r.nUsed>0 && P("render")!=0 ){
994
/* Maybe return fossil-rendered form of the content. */
995
Blob r2 = BLOB_INITIALIZER; /* output target for rendering */
996
const char * zMime2 = 0; /* adjusted response mimetype */
997
if(fossil_strcmp(zMime, "text/x-markdown")==0
998
/* Firefox uploads md files with the mimetype text/markdown */
999
|| fossil_strcmp(zMime, "text/markdown")==0){
1000
markdown_to_html(&r, 0, &r2);
1001
safe_html(&r2);
1002
zMime2 = "text/html";
1003
bCheckedMimetype = 1;
1004
}else if(fossil_strcmp(zMime, "text/x-fossil-wiki")==0
1005
|| sqlite3_strglob("*.wiki", zName)==0){
1006
/* .wiki files get uploaded as application/octet-stream */
1007
wiki_convert(&r, &r2, 0);
1008
zMime2 = "text/html";
1009
bCheckedMimetype = 1;
1010
}else if(fossil_strcmp(zMime, "text/x-pikchr")==0
1011
|| sqlite3_strglob("*.pikchr",zName)==0){
1012
/* .pikchr files get uploaded as application/octet-stream */
1013
const char *zPikchr = blob_str(&r);
1014
int w = 0, h = 0;
1015
char *zOut = pikchr(zPikchr, "pikchr", 0, &w, &h);
1016
if(zOut){
1017
blob_append(&r2, zOut, -1);
1018
}
1019
zMime2 = w>0 ? "image/svg+xml" : "text/html";
1020
free(zOut);
1021
bCheckedMimetype = 1;
1022
}
1023
if(r2.aData!=0){
1024
blob_swap(&r, &r2);
1025
blob_reset(&r2);
1026
zMime = zMime2;
1027
}
1028
}
1029
if( bCheckedMimetype==0 && sqlite3_strglob("text/*", zMime)==0 ){
1030
/* The problem: both Chrome and Firefox upload *.patch with
1031
** the mimetype text/x-patch, whereas we very often use that
1032
** name glob for fossil-format patches. That causes such files
1033
** to attempt to render in the browser when clicked via
1034
** download links in chat.
1035
**
1036
** The workaround: */
1037
if( looks_like_binary(&r) ){
1038
zMime = "application/octet-stream";
1039
}
1040
}
1041
cgi_set_content_type(zMime);
1042
cgi_set_content(&r);
1043
}
1044
1045
1046
/*
1047
** WEBPAGE: chat-delete hidden loadavg-exempt
1048
**
1049
** Delete the chat entry identified by the name query parameter.
1050
** Invoking fetch("chat-delete/"+msgid) from javascript in the client
1051
** will delete a chat entry from the CHAT table.
1052
**
1053
** This routine both deletes the identified chat entry and also inserts
1054
** a new entry with the current timestamp and with:
1055
**
1056
** * xmsg = NULL
1057
**
1058
** * file = NULL
1059
**
1060
** * mdel = The msgid of the row that was deleted
1061
**
1062
** This new entry will then be propagated to all listeners so that they
1063
** will know to delete their copies of the message too.
1064
*/
1065
void chat_delete_webpage(void){
1066
int mdel;
1067
char *zOwner;
1068
login_check_credentials();
1069
if( !g.perm.Chat ) return;
1070
chat_create_tables();
1071
mdel = atoi(PD("name","0"));
1072
zOwner = db_text(0, "SELECT xfrom FROM chat WHERE msgid=%d", mdel);
1073
if( zOwner==0 ) return;
1074
if( fossil_strcmp(zOwner, g.zLogin)!=0 && !g.perm.Admin ) return;
1075
db_multi_exec(
1076
"PRAGMA secure_delete=ON;\n"
1077
"BEGIN;\n"
1078
"DELETE FROM chat WHERE msgid=%d;\n"
1079
"INSERT INTO chat(mtime, xfrom, mdel)"
1080
" VALUES(julianday('now'), %Q, %d);\n"
1081
"COMMIT;",
1082
mdel, g.zLogin, mdel
1083
);
1084
}
1085
1086
/*
1087
** WEBPAGE: chat-backup hidden
1088
**
1089
** Download an SQLite database containing all chat content with a
1090
** message-id larger than the "msgid" query parameter. Setup
1091
** privilege is required to use this URL.
1092
**
1093
** This is used to implement the "fossil chat pull" command.
1094
*/
1095
void chat_backup_webpage(void){
1096
int msgid;
1097
unsigned char *pDb = 0;
1098
sqlite3_int64 szDb = 0;
1099
Blob chatDb;
1100
login_check_credentials();
1101
if( !g.perm.Setup ) return;
1102
msgid = atoi(PD("msgid","0"));
1103
db_multi_exec(
1104
"ATTACH ':memory:' AS mem1;\n"
1105
"PRAGMA mem1.page_size=512;\n"
1106
"CREATE TABLE mem1.chat AS SELECT * FROM repository.chat WHERE msgid>%d;\n",
1107
msgid
1108
);
1109
pDb = sqlite3_serialize(g.db, "mem1", &szDb, 0);
1110
if( pDb==0 ){
1111
fossil_fatal("Out of memory");
1112
}
1113
blob_init(&chatDb, (const char*)pDb, (int)szDb);
1114
cgi_set_content_type("application/x-sqlite3");
1115
cgi_set_content(&chatDb);
1116
}
1117
1118
/*
1119
** SQL Function: chat_msg_from_event(TYPE,OBJID,USER,MSG)
1120
**
1121
** This function returns HTML text that describes an entry from the EVENT
1122
** table (that is, a timeline event) for display in chat. Parameters:
1123
**
1124
** TYPE The event type. 'ci', 'w', 't', 'g', and so forth
1125
** OBJID EVENT.OBJID
1126
** USER coalesce(EVENT.EUSER,EVENT.USER)
1127
** MSG coalesce(EVENT.ECOMMENT, EVENT.COMMENT)
1128
**
1129
** This function is intended to be called by the temp.chat_trigger1 trigger
1130
** which is created by alert_create_trigger() routine.
1131
*/
1132
void chat_msg_from_event(
1133
sqlite3_context *context,
1134
int argc,
1135
sqlite3_value **argv
1136
){
1137
const char *zType = (const char*)sqlite3_value_text(argv[0]);
1138
int rid = sqlite3_value_int(argv[1]);
1139
const char *zUser = (const char*)sqlite3_value_text(argv[2]);
1140
const char *zMsg = (const char*)sqlite3_value_text(argv[3]);
1141
char *zRes = 0;
1142
1143
if( zType==0 || zUser==0 || zMsg==0 ) return;
1144
if( zType[0]=='c' ){
1145
/* Check-ins */
1146
char *zBranch;
1147
char *zUuid;
1148
1149
zBranch = db_text(0,
1150
"SELECT value FROM tagxref"
1151
" WHERE tagxref.rid=%d"
1152
" AND tagxref.tagid=%d"
1153
" AND tagxref.tagtype>0",
1154
rid, TAG_BRANCH);
1155
zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid);
1156
zRes = mprintf("%W (check-in: <a href='%R/info/%S'>%S</a>, "
1157
"user: <a href='%R/timeline?u=%t&c=%S'>%h</a>, "
1158
"branch: <a href='%R/timeline?r=%t&c=%S'>%h</a>)",
1159
zMsg,
1160
zUuid, zUuid,
1161
zUser, zUuid, zUser,
1162
zBranch, zUuid, zBranch
1163
);
1164
fossil_free(zBranch);
1165
fossil_free(zUuid);
1166
}else if( zType[0]=='w' ){
1167
/* Wiki page changes */
1168
char *zUuid = rid_to_uuid(rid);
1169
wiki_hyperlink_override(zUuid);
1170
if( zMsg[0]=='-' ){
1171
zRes = mprintf("Delete wiki page <a href='%R/whistory?name=%t'>%h</a>",
1172
zMsg+1, zMsg+1);
1173
}else if( zMsg[0]=='+' ){
1174
zRes = mprintf("Added wiki page <a href='%R/whistory?name=%t'>%h</a>",
1175
zMsg+1, zMsg+1);
1176
}else if( zMsg[0]==':' ){
1177
zRes = mprintf("<a href='%R/wdiff?id=%!S'>Changes</a> to wiki page "
1178
"<a href='%R/whistory?name=%t'>%h</a>",
1179
zUuid, zMsg+1, zMsg+1);
1180
}else{
1181
zRes = mprintf("%W", zMsg);
1182
}
1183
wiki_hyperlink_override(0);
1184
fossil_free(zUuid);
1185
}else if( zType[0]=='f' ){
1186
/* Forum changes */
1187
char *zUuid = rid_to_uuid(rid);
1188
zRes = mprintf( "%W (artifact: <a href='%R/info/%S'>%S</a>, "
1189
"user: <a href='%R/timeline?u=%t&c=%S'>%h</a>)",
1190
zMsg, zUuid, zUuid, zUser, zUuid, zUser);
1191
fossil_free(zUuid);
1192
}else{
1193
/* Anything else */
1194
zRes = mprintf("%W", zMsg);
1195
}
1196
if( zRes ){
1197
sqlite3_result_text(context, zRes, -1, fossil_free);
1198
}
1199
}
1200
1201
1202
/*
1203
** COMMAND: chat
1204
**
1205
** Usage: %fossil chat [SUBCOMMAND] [--remote URL] [ARGS...]
1206
**
1207
** This command performs actions associated with the /chat instance
1208
** on the default remote Fossil repository (the Fossil repository whose
1209
** URL shows when you run the "fossil remote" command) or to the URL
1210
** specified by the --remote option. If there is no default remote
1211
** Fossil repository and the --remote option is omitted, then this
1212
** command fails with an error.
1213
**
1214
** Subcommands:
1215
**
1216
** > fossil chat
1217
**
1218
** When there is no SUBCOMMAND (when this command is simply "fossil chat")
1219
** the response is to bring up a web-browser window to the chatroom
1220
** on the default system web-browser. You can accomplish the same by
1221
** typing the appropriate URL into the web-browser yourself. This
1222
** command is merely a convenience for command-line oriented people.
1223
**
1224
** > fossil chat pull
1225
**
1226
** Copy chat content from the server down into the local clone,
1227
** as a backup or archive. Setup privilege is required on the server.
1228
**
1229
** --all Download all chat content. Normally only
1230
** previously undownloaded content is retrieved.
1231
** --debug Additional debugging output
1232
** --out DATABASE Store CHAT table in separate database file
1233
** DATABASE rather than adding to local clone
1234
** --unsafe Allow the use of unencrypted http://
1235
**
1236
** > fossil chat send [ARGUMENTS]
1237
**
1238
** This command sends a new message to the chatroom. The message
1239
** to be sent is determined by arguments as follows:
1240
**
1241
** -f|--file FILENAME File to attach to the message
1242
** --as FILENAME2 Causes --file FILENAME to be sent with
1243
** the attachment name FILENAME2
1244
** -m|--message TEXT Text of the chat message
1245
** --remote URL Send to this remote URL
1246
** --unsafe Allow the use of unencrypted http://
1247
**
1248
** > fossil chat url
1249
**
1250
** Show the default URL used to access the chat server.
1251
**
1252
** > fossil chat purge
1253
**
1254
** Remove chat messages that are older than chat-keep-days and
1255
** which are not one of the most recent chat-keep-count message.
1256
**
1257
** > fossil chat reindex
1258
**
1259
** Rebuild the full-text search index for chat
1260
**
1261
** Additional subcommands may be added in the future.
1262
*/
1263
void chat_command(void){
1264
const char *zUrl = find_option("remote",0,1);
1265
int urlFlags = 0;
1266
int isDefaultUrl = 0;
1267
int i;
1268
1269
db_find_and_open_repository(0,0);
1270
if( zUrl ){
1271
urlFlags = URL_PROMPT_PW;
1272
}else{
1273
zUrl = db_get("last-sync-url",0);
1274
if( zUrl==0 ){
1275
fossil_fatal("no \"remote\" repository defined");
1276
}else{
1277
isDefaultUrl = 1;
1278
}
1279
}
1280
url_parse(zUrl, urlFlags);
1281
if( g.url.isFile || g.url.isSsh ){
1282
fossil_fatal("chat only works for http:// and https:// URLs");
1283
}
1284
i = (int)strlen(g.url.path);
1285
while( i>0 && g.url.path[i-1]=='/' ) i--;
1286
if( g.url.port==g.url.dfltPort ){
1287
zUrl = mprintf(
1288
"%s://%T%.*T",
1289
g.url.protocol, g.url.name, i, g.url.path
1290
);
1291
}else{
1292
zUrl = mprintf(
1293
"%s://%T:%d%.*T",
1294
g.url.protocol, g.url.name, g.url.port, i, g.url.path
1295
);
1296
}
1297
if( g.argc==2 ){
1298
const char *zBrowser = fossil_web_browser();
1299
char *zCmd;
1300
verify_all_options();
1301
if( zBrowser==0 ) return;
1302
#ifdef _WIN32
1303
zCmd = mprintf("%s %s/chat?cli &", zBrowser, zUrl);
1304
#else
1305
zCmd = mprintf("%s \"%s/chat?cli\" &", zBrowser, zUrl);
1306
#endif
1307
fossil_system(zCmd);
1308
}else if( strcmp(g.argv[2],"send")==0 ){
1309
const char *zFilename = find_option("file","f",1);
1310
const char *zAs = find_option("as",0,1);
1311
const char *zMsg = find_option("message","m",1);
1312
int allowUnsafe = find_option("unsafe",0,0)!=0;
1313
const int mFlags = HTTP_GENERIC | HTTP_QUIET | HTTP_NOCOMPRESS;
1314
int i;
1315
const char *zPw;
1316
char *zLMTime;
1317
Blob up, down, fcontent;
1318
char zBoundary[80];
1319
sqlite3_uint64 r[3];
1320
if( zFilename==0 && zMsg==0 ){
1321
fossil_fatal("must have --message or --file or both");
1322
}
1323
if( !g.url.isHttps && !allowUnsafe ){
1324
fossil_fatal("URL \"%s\" is unencrypted. Use https:// instead", zUrl);
1325
}
1326
verify_all_options();
1327
if( g.argc>3 ){
1328
fossil_fatal("unknown extra argument: \"%s\"", g.argv[3]);
1329
}
1330
i = (int)strlen(g.url.path);
1331
while( i>0 && g.url.path[i-1]=='/' ) i--;
1332
g.url.path = mprintf("%.*s/chat-send", i, g.url.path);
1333
blob_init(&up, 0, 0);
1334
blob_init(&down, 0, 0);
1335
sqlite3_randomness(sizeof(r),r);
1336
sqlite3_snprintf(sizeof(zBoundary),zBoundary,
1337
"--------%016llu%016llu%016llu", r[0], r[1], r[2]);
1338
blob_appendf(&up, "%s", zBoundary);
1339
zLMTime = db_text(0,
1340
"SELECT strftime('%%Y-%%m-%%dT%%H:%%M:%%S','now','localtime')");
1341
if( zLMTime ){
1342
blob_appendf(&up,"\r\nContent-Disposition: form-data; name=\"lmtime\"\r\n"
1343
"\r\n%z\r\n%s", zLMTime, zBoundary);
1344
}
1345
if( g.url.user && g.url.user[0] ){
1346
blob_appendf(&up,"\r\nContent-Disposition: form-data; name=\"resid\"\r\n"
1347
"\r\n%z\r\n%s", obscure(g.url.user), zBoundary);
1348
}
1349
zPw = g.url.passwd;
1350
if( zPw==0 && isDefaultUrl ) zPw = unobscure(db_get("last-sync-pw", 0));
1351
if( zPw && zPw[0] ){
1352
blob_appendf(&up,"\r\nContent-Disposition: form-data; name=\"token\"\r\n"
1353
"\r\n%z\r\n%s", obscure(zPw), zBoundary);
1354
}
1355
if( zMsg && zMsg[0] ){
1356
blob_appendf(&up,"\r\nContent-Disposition: form-data; name=\"msg\"\r\n"
1357
"\r\n%s\r\n%s", zMsg, zBoundary);
1358
}
1359
if( zFilename && blob_read_from_file(&fcontent, zFilename, ExtFILE)>0 ){
1360
char *zFN = fossil_strdup(file_tail(zAs ? zAs : zFilename));
1361
int i;
1362
const char *zMime = mimetype_from_name(zFN);
1363
for(i=0; zFN[i]; i++){
1364
char c = zFN[i];
1365
if( fossil_isalnum(c) ) continue;
1366
if( c=='.' ) continue;
1367
if( c=='-' ) continue;
1368
zFN[i] = '_';
1369
}
1370
blob_appendf(&up,"\r\nContent-Disposition: form-data; name=\"file\";"
1371
" filename=\"%s\"\r\n", zFN);
1372
blob_appendf(&up,"Content-Type: %s\r\n\r\n", zMime);
1373
blob_append(&up, fcontent.aData, fcontent.nUsed);
1374
blob_appendf(&up,"\r\n%s", zBoundary);
1375
}
1376
blob_append(&up,"--\r\n", 4);
1377
http_exchange(&up, &down, mFlags, 4, "multipart/form-data");
1378
blob_reset(&up);
1379
if( sqlite3_strglob("{\"isError\": true,*", blob_str(&down))==0 ){
1380
if( strstr(blob_str(&down), "not logged in")!=0 ){
1381
fossil_print("ERROR: username and/or password is incorrect\n");
1382
}else{
1383
fossil_print("ERROR: %s\n", blob_str(&down));
1384
}
1385
fossil_fatal("unable to send the chat message");
1386
}
1387
blob_reset(&down);
1388
}else if( strcmp(g.argv[2],"pull")==0 ){
1389
/* Pull the CHAT table from the default server down into the repository
1390
** here on the local side */
1391
int allowUnsafe = find_option("unsafe",0,0)!=0;
1392
int bDebug = find_option("debug",0,0)!=0;
1393
const char *zOut = find_option("out",0,1);
1394
int bAll = find_option("all",0,0)!=0;
1395
int mFlags = HTTP_GENERIC | HTTP_QUIET | HTTP_NOCOMPRESS;
1396
int msgid;
1397
Blob reqUri; /* The REQUEST_URI: .../chat-backup?msgid=... */
1398
char *zObs;
1399
const char *zPw;
1400
Blob up, down;
1401
int nChat;
1402
int rc;
1403
verify_all_options();
1404
chat_create_tables();
1405
msgid = bAll ? 0 : db_int(0,"SELECT max(msgid) FROM chat");
1406
if( !g.url.isHttps && !allowUnsafe ){
1407
fossil_fatal("URL \"%s\" is unencrypted. Use https:// instead", zUrl);
1408
}
1409
blob_init(&reqUri, g.url.path, -1);
1410
blob_appendf(&reqUri, "/chat-backup?msgid=%d", msgid);
1411
if( g.url.user && g.url.user[0] ){
1412
zObs = obscure(g.url.user);
1413
blob_appendf(&reqUri, "&resid=%t", zObs);
1414
fossil_free(zObs);
1415
}
1416
zPw = g.url.passwd;
1417
if( zPw==0 && isDefaultUrl ){
1418
zPw = unobscure(db_get("last-sync-pw", 0));
1419
if( zPw==0 ){
1420
/* Can happen if "remember password" is not used. */
1421
g.url.flags |= URL_PROMPT_PW;
1422
url_prompt_for_password();
1423
zPw = g.url.passwd;
1424
}
1425
}
1426
if( zPw && zPw[0] ){
1427
zObs = obscure(zPw);
1428
blob_appendf(&reqUri, "&token=%t", zObs);
1429
fossil_free(zObs);
1430
}
1431
g.url.path = blob_str(&reqUri);
1432
if( bDebug ){
1433
fossil_print("REQUEST_URI: %s\n", g.url.path);
1434
mFlags &= ~HTTP_QUIET;
1435
mFlags |= HTTP_VERBOSE;
1436
}
1437
blob_init(&up, 0, 0);
1438
blob_init(&down, 0, 0);
1439
http_exchange(&up, &down, mFlags, 4, 0);
1440
if( zOut ){
1441
blob_write_to_file(&down, zOut);
1442
fossil_print("Chat database at %s is %d bytes\n", zOut, blob_size(&down));
1443
}else{
1444
db_multi_exec("ATTACH ':memory:' AS chatbu;");
1445
if( g.fSqlTrace ){
1446
fossil_trace("-- deserialize(\"chatbu\", pData, %d);\n",
1447
blob_size(&down));
1448
}
1449
rc = sqlite3_deserialize(g.db, "chatbu",
1450
(unsigned char*)blob_buffer(&down),
1451
blob_size(&down), blob_size(&down), 0);
1452
if( rc ){
1453
fossil_fatal("cannot open patch database: %s", sqlite3_errmsg(g.db));
1454
}
1455
nChat = db_int(0, "SELECT count(*) FROM chatbu.chat");
1456
fossil_print("Got %d new records, %d bytes\n", nChat, blob_size(&down));
1457
db_multi_exec(
1458
"REPLACE INTO repository.chat(msgid,mtime,lmtime,xfrom,xmsg,"
1459
"fname,fmime,mdel,file)"
1460
" SELECT msgid,mtime,lmtime,xfrom,xmsg,fname,fmime,mdel,file"
1461
" FROM chatbu.chat;"
1462
);
1463
}
1464
}else if( strcmp(g.argv[2],"url")==0 ){
1465
/* Show the URL to access chat. */
1466
fossil_print("%s/chat\n", zUrl);
1467
}else if( strcmp(g.argv[2],"purge")==0 ){
1468
/* clear out expired chat messages: chat messages that are older then
1469
** chat-keep-days and that are not one or the most recent chat-keep-count
1470
** messages. */
1471
chat_create_tables();
1472
chat_purge();
1473
}else if( strcmp(g.argv[2],"reindex")==0 ){
1474
/* Rebuild the FTS5 index on chat content */
1475
chat_rebuild_index(1);
1476
}else{
1477
fossil_fatal("no such subcommand \"%s\". Use --help for help", g.argv[2]);
1478
}
1479
}
1480

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button