Fossil SCM

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

Keyboard Shortcuts

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