Fossil SCM

Hyperlink processing for chat messages is now handled on the server side, where we have knowledge of interwiki links, wiki page names, and valid artifact hashes.

drh 2020-12-24 13:44 UTC trunk
Commit 822653c26952d70c3482227e73f11cb84aa39331d6717d6ea2758ec3062b15fc
+94 -4
--- src/chat.c
+++ src/chat.c
@@ -243,10 +243,103 @@
243243
db_step(&q);
244244
db_finalize(&q);
245245
blob_reset(&b);
246246
}
247247
}
248
+
249
+/*
250
+** This routine receives raw (user-entered) message text and transforms
251
+** it into HTML that is safe to insert using innerHTML.
252
+**
253
+** * HTML in the original text is escaped.
254
+**
255
+** * Hyperlinks are identified and tagged. Hyperlinks are:
256
+**
257
+** - Undelimited text of the form https:... or http:...
258
+** - Any text enclosed within [...]
259
+**
260
+** Space to hold the returned string is obtained from fossil_malloc()
261
+** and must be freed by the caller.
262
+*/
263
+static char *chat_format_to_html(const char *zMsg){
264
+ char *zSafe = mprintf("%h", zMsg);
265
+ int i, j, k;
266
+ Blob out;
267
+ char zClose[20];
268
+ blob_init(&out, 0, 0);
269
+ for(i=j=0; zSafe[i]; i++){
270
+ if( zSafe[i]=='[' ){
271
+ for(k=i+1; zSafe[k] && zSafe[k]!=']'; k++){}
272
+ if( zSafe[k]==']' ){
273
+ zSafe[k] = 0;
274
+ if( j<i ){
275
+ blob_append(&out, zSafe + j, i-j);
276
+ j = i;
277
+ }
278
+ wiki_resolve_hyperlink(&out, WIKI_NOBADLINKS|WIKI_TARGET_BLANK,
279
+ zSafe+i+1, zClose, sizeof(zClose), zSafe, 0);
280
+ zSafe[k] = ']';
281
+ j++;
282
+ blob_append(&out, zSafe + j, k - j);
283
+ blob_append(&out, zClose, -1);
284
+ i = k;
285
+ j = k+1;
286
+ continue;
287
+ }
288
+ }else if( zSafe[i]=='h'
289
+ && (strncmp(zSafe+i,"http:",5)==0
290
+ || strncmp(zSafe+i,"https:",6)==0) ){
291
+ for(k=i+1; zSafe[k] && !fossil_isspace(zSafe[k]); k++){}
292
+ if( zSafe[k] && k>i+7 ){
293
+ char c = zSafe[k];
294
+ if( !fossil_isalnum(zSafe[k-1]) && zSafe[k-1]!='/' ){
295
+ k--;
296
+ c = zSafe[k];
297
+ }
298
+ if( j<i ){
299
+ blob_append(&out, zSafe + j, i-j);
300
+ j = i;
301
+ }
302
+ zSafe[k] = 0;
303
+ wiki_resolve_hyperlink(&out, WIKI_NOBADLINKS|WIKI_TARGET_BLANK,
304
+ zSafe+i, zClose, sizeof(zClose), zSafe, 0);
305
+ zSafe[k] = c;
306
+ blob_append(&out, zSafe + j, k - j);
307
+ blob_append(&out, zClose, -1);
308
+ i = j = k;
309
+ continue;
310
+ }
311
+ }
312
+ }
313
+ if( j<i ){
314
+ blob_append(&out, zSafe+j, j-i);
315
+ }
316
+ fossil_free(zSafe);
317
+ return blob_str(&out);
318
+}
319
+
320
+/*
321
+** COMMAND: test-chat-formatter
322
+**
323
+** Usage: %fossil test-chat-formatter STRING ...
324
+**
325
+** Transform each argument string into HTML that will display the
326
+** chat message. This is used to test the formatter and to verify
327
+** that a malicious message text will not cause HTML or JS injection
328
+** into the chat display in a browser.
329
+*/
330
+void chat_test_formatter_cmd(void){
331
+ int i;
332
+ char *zOut;
333
+ db_find_and_open_repository(0,0);
334
+ g.perm.Hyperlink = 1;
335
+ for(i=0; i<g.argc; i++){
336
+ zOut = chat_format_to_html(g.argv[i]);
337
+ fossil_print("[%d]: %s\n", i, zOut);
338
+ fossil_free(zOut);
339
+ }
340
+}
248341
249342
/*
250343
** WEBPAGE: chat-poll
251344
**
252345
** The chat page generated by /chat using a XHR to this page in order
@@ -336,14 +429,11 @@
336429
zSep = ",\n";
337430
blob_appendf(&json, "{\"msgid\":%d,\"mtime\":%!j,", id, zDate);
338431
blob_appendf(&json, "\"xfrom\":%!j,", zFrom);
339432
blob_appendf(&json, "\"uclr\":%!j,", hash_color(zFrom));
340433
341
- /* TBD: Convert the raw message into HTML, perhaps by running it
342
- ** through a text formatter, or putting markup on @name phrases,
343
- ** etc. */
344
- zMsg = mprintf("%h", zRawMsg ? zRawMsg : "");
434
+ zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "");
345435
blob_appendf(&json, "\"xmsg\":%!j,", zMsg);
346436
fossil_free(zMsg);
347437
348438
if( nByte==0 ){
349439
blob_appendf(&json, "\"fsize\":0");
350440
--- src/chat.c
+++ src/chat.c
@@ -243,10 +243,103 @@
243 db_step(&q);
244 db_finalize(&q);
245 blob_reset(&b);
246 }
247 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
249 /*
250 ** WEBPAGE: chat-poll
251 **
252 ** The chat page generated by /chat using a XHR to this page in order
@@ -336,14 +429,11 @@
336 zSep = ",\n";
337 blob_appendf(&json, "{\"msgid\":%d,\"mtime\":%!j,", id, zDate);
338 blob_appendf(&json, "\"xfrom\":%!j,", zFrom);
339 blob_appendf(&json, "\"uclr\":%!j,", hash_color(zFrom));
340
341 /* TBD: Convert the raw message into HTML, perhaps by running it
342 ** through a text formatter, or putting markup on @name phrases,
343 ** etc. */
344 zMsg = mprintf("%h", zRawMsg ? zRawMsg : "");
345 blob_appendf(&json, "\"xmsg\":%!j,", zMsg);
346 fossil_free(zMsg);
347
348 if( nByte==0 ){
349 blob_appendf(&json, "\"fsize\":0");
350
--- src/chat.c
+++ src/chat.c
@@ -243,10 +243,103 @@
243 db_step(&q);
244 db_finalize(&q);
245 blob_reset(&b);
246 }
247 }
248
249 /*
250 ** This routine receives raw (user-entered) message text and transforms
251 ** it into HTML that is safe to insert using innerHTML.
252 **
253 ** * HTML in the original text is escaped.
254 **
255 ** * Hyperlinks are identified and tagged. Hyperlinks are:
256 **
257 ** - Undelimited text of the form https:... or http:...
258 ** - Any text enclosed within [...]
259 **
260 ** Space to hold the returned string is obtained from fossil_malloc()
261 ** and must be freed by the caller.
262 */
263 static char *chat_format_to_html(const char *zMsg){
264 char *zSafe = mprintf("%h", zMsg);
265 int i, j, k;
266 Blob out;
267 char zClose[20];
268 blob_init(&out, 0, 0);
269 for(i=j=0; zSafe[i]; i++){
270 if( zSafe[i]=='[' ){
271 for(k=i+1; zSafe[k] && zSafe[k]!=']'; k++){}
272 if( zSafe[k]==']' ){
273 zSafe[k] = 0;
274 if( j<i ){
275 blob_append(&out, zSafe + j, i-j);
276 j = i;
277 }
278 wiki_resolve_hyperlink(&out, WIKI_NOBADLINKS|WIKI_TARGET_BLANK,
279 zSafe+i+1, zClose, sizeof(zClose), zSafe, 0);
280 zSafe[k] = ']';
281 j++;
282 blob_append(&out, zSafe + j, k - j);
283 blob_append(&out, zClose, -1);
284 i = k;
285 j = k+1;
286 continue;
287 }
288 }else if( zSafe[i]=='h'
289 && (strncmp(zSafe+i,"http:",5)==0
290 || strncmp(zSafe+i,"https:",6)==0) ){
291 for(k=i+1; zSafe[k] && !fossil_isspace(zSafe[k]); k++){}
292 if( zSafe[k] && k>i+7 ){
293 char c = zSafe[k];
294 if( !fossil_isalnum(zSafe[k-1]) && zSafe[k-1]!='/' ){
295 k--;
296 c = zSafe[k];
297 }
298 if( j<i ){
299 blob_append(&out, zSafe + j, i-j);
300 j = i;
301 }
302 zSafe[k] = 0;
303 wiki_resolve_hyperlink(&out, WIKI_NOBADLINKS|WIKI_TARGET_BLANK,
304 zSafe+i, zClose, sizeof(zClose), zSafe, 0);
305 zSafe[k] = c;
306 blob_append(&out, zSafe + j, k - j);
307 blob_append(&out, zClose, -1);
308 i = j = k;
309 continue;
310 }
311 }
312 }
313 if( j<i ){
314 blob_append(&out, zSafe+j, j-i);
315 }
316 fossil_free(zSafe);
317 return blob_str(&out);
318 }
319
320 /*
321 ** COMMAND: test-chat-formatter
322 **
323 ** Usage: %fossil test-chat-formatter STRING ...
324 **
325 ** Transform each argument string into HTML that will display the
326 ** chat message. This is used to test the formatter and to verify
327 ** that a malicious message text will not cause HTML or JS injection
328 ** into the chat display in a browser.
329 */
330 void chat_test_formatter_cmd(void){
331 int i;
332 char *zOut;
333 db_find_and_open_repository(0,0);
334 g.perm.Hyperlink = 1;
335 for(i=0; i<g.argc; i++){
336 zOut = chat_format_to_html(g.argv[i]);
337 fossil_print("[%d]: %s\n", i, zOut);
338 fossil_free(zOut);
339 }
340 }
341
342 /*
343 ** WEBPAGE: chat-poll
344 **
345 ** The chat page generated by /chat using a XHR to this page in order
@@ -336,14 +429,11 @@
429 zSep = ",\n";
430 blob_appendf(&json, "{\"msgid\":%d,\"mtime\":%!j,", id, zDate);
431 blob_appendf(&json, "\"xfrom\":%!j,", zFrom);
432 blob_appendf(&json, "\"uclr\":%!j,", hash_color(zFrom));
433
434 zMsg = chat_format_to_html(zRawMsg ? zRawMsg : "");
 
 
 
435 blob_appendf(&json, "\"xmsg\":%!j,", zMsg);
436 fossil_free(zMsg);
437
438 if( nByte==0 ){
439 blob_appendf(&json, "\"fsize\":0");
440
+10 -77
--- src/chat.js
+++ src/chat.js
@@ -281,86 +281,10 @@
281281
x -= pRect.width/3*2;
282282
}
283283
f.popup.show(x, y);
284284
};
285285
286
- /**
287
- Parses the given chat message string for hyperlinks and @NAME
288
- references, replacing them with markup, then stuffs the parsed
289
- content into the given target DOM element.
290
-
291
- This is an intermediary step until we get some basic markup
292
- support coming from the server side.
293
- */
294
- const messageToDOM = function f(str, tgtElem){
295
- "use strict";
296
- if(!f.rxUrl){
297
- f.rxUrl = /\b(?:https?|ftp):\/\/[a-z0-9-+&@#\/%?=~_|!:,.;]*[a-z0-9-+&@#\/%=~_|]/gim;
298
- f.rxAt = /@\w+/gmi;
299
- f.rxNS = /\S/;
300
- f.ce = (T)=>document.createElement(T);
301
- f.ct = (T)=>document.createTextNode(T);
302
- f.replaceUrls = function ff(sub, offset, whole){
303
- if(offset > ff.prevStart){
304
- f.accum.push((ff.prevStart?' ':'')+whole.substring(ff.prevStart, offset-1)+' ');
305
- }
306
- const a = f.ce('a');
307
- a.setAttribute('href',sub);
308
- a.setAttribute('target','_blank');
309
- a.appendChild(f.ct(sub));
310
- f.accum.push(a);
311
- ff.prevStart = offset + sub.length + 1;
312
- };
313
- f.replaceAtName = function ff(sub, offset,whole){
314
- if(offset > ff.prevStart){
315
- ff.accum.push((ff.prevStart?' ':'')+whole.substring(ff.prevStart, offset-1)+' ');
316
- }else if(offset && f.rxNS.test(whole[offset-1])){
317
- // Sigh: https://stackoverflow.com/questions/52655367
318
- ff.accum.push(sub);
319
- return;
320
- }
321
- const e = f.ce('span');
322
- e.classList.add('at-name');
323
- e.appendChild(f.ct(sub));
324
- ff.accum.push(e);
325
- ff.prevStart = offset + sub.length + 1;
326
- };
327
- }
328
- f.accum = []; // accumulate strings and DOM elements here.
329
- f.rxUrl.lastIndex = f.replaceUrls.prevStart = 0; // reset regex cursor
330
- str.replace(f.rxUrl, f.replaceUrls);
331
- // Push remaining non-URL part of the string to the queue...
332
- if(f.replaceUrls.prevStart < str.length){
333
- f.accum.push((f.replaceUrls.prevStart?' ':'')+str.substring(f.replaceUrls.prevStart));
334
- }
335
- // Pass 2: process @NAME references...
336
- // TODO: only match NAME if it's the name of a currently participating
337
- // user. Add a second class if NAME == current user, and style that one
338
- // differently so that people can more easily see when they're spoken to.
339
- const accum2 = f.replaceAtName.accum = [];
340
- f.accum.forEach(function(v){
341
- if('string'===typeof v){
342
- f.rxAt.lastIndex = f.replaceAtName.prevStart = 0;
343
- v.replace(f.rxAt, f.replaceAtName);
344
- if(f.replaceAtName.prevStart < v.length){
345
- accum2.push((f.replaceAtName.prevStart?' ':'')+v.substring(f.replaceAtName.prevStart));
346
- }
347
- }else{
348
- accum2.push(v);
349
- }
350
- });
351
- delete f.accum;
352
- const theTgt = tgtElem || f.ce('div');
353
- const strings = [];
354
- accum2.forEach(function(e){
355
- if('string'===typeof e) strings.push(e);
356
- else strings.push(e.outerHTML);
357
- });
358
- D.parseHtml(theTgt, strings.join(''));
359
- return theTgt;
360
- }/*end messageToDOM()*/;
361
-
362286
/** Callback for poll() to inject new content into the page. */
363287
function newcontent(jx){
364288
var i;
365289
if('visible'===document.visibilityState){
366290
if(Chat.changesSincePageHidden){
@@ -423,11 +347,20 @@
423347
const br = D.br();
424348
br.style.clear = "both";
425349
eContent.appendChild(br);
426350
}
427351
if(m.xmsg){
428
- messageToDOM(m.xmsg, eContent);
352
+ // The m.xmsg text comes from the same server as this script and
353
+ // is guaranteed by that server to be "safe" HTML - safe in the
354
+ // sense that it is not possible for a malefactor to inject HTML
355
+ // or javascript or CSS. The m.xmsg content might contain
356
+ // hyperlinks, but otherwise it will be markup-free. See the
357
+ // chat_format_to_html() routine in the server for details.
358
+ //
359
+ // Hence, even though innerHTML is normally frowned upon, it is
360
+ // perfectly safe to use in this context.
361
+ eContent.innerHTML += m.xmsg
429362
}
430363
eContent.classList.add('chat-message');
431364
}
432365
}
433366
async function poll(){
434367
--- src/chat.js
+++ src/chat.js
@@ -281,86 +281,10 @@
281 x -= pRect.width/3*2;
282 }
283 f.popup.show(x, y);
284 };
285
286 /**
287 Parses the given chat message string for hyperlinks and @NAME
288 references, replacing them with markup, then stuffs the parsed
289 content into the given target DOM element.
290
291 This is an intermediary step until we get some basic markup
292 support coming from the server side.
293 */
294 const messageToDOM = function f(str, tgtElem){
295 "use strict";
296 if(!f.rxUrl){
297 f.rxUrl = /\b(?:https?|ftp):\/\/[a-z0-9-+&@#\/%?=~_|!:,.;]*[a-z0-9-+&@#\/%=~_|]/gim;
298 f.rxAt = /@\w+/gmi;
299 f.rxNS = /\S/;
300 f.ce = (T)=>document.createElement(T);
301 f.ct = (T)=>document.createTextNode(T);
302 f.replaceUrls = function ff(sub, offset, whole){
303 if(offset > ff.prevStart){
304 f.accum.push((ff.prevStart?' ':'')+whole.substring(ff.prevStart, offset-1)+' ');
305 }
306 const a = f.ce('a');
307 a.setAttribute('href',sub);
308 a.setAttribute('target','_blank');
309 a.appendChild(f.ct(sub));
310 f.accum.push(a);
311 ff.prevStart = offset + sub.length + 1;
312 };
313 f.replaceAtName = function ff(sub, offset,whole){
314 if(offset > ff.prevStart){
315 ff.accum.push((ff.prevStart?' ':'')+whole.substring(ff.prevStart, offset-1)+' ');
316 }else if(offset && f.rxNS.test(whole[offset-1])){
317 // Sigh: https://stackoverflow.com/questions/52655367
318 ff.accum.push(sub);
319 return;
320 }
321 const e = f.ce('span');
322 e.classList.add('at-name');
323 e.appendChild(f.ct(sub));
324 ff.accum.push(e);
325 ff.prevStart = offset + sub.length + 1;
326 };
327 }
328 f.accum = []; // accumulate strings and DOM elements here.
329 f.rxUrl.lastIndex = f.replaceUrls.prevStart = 0; // reset regex cursor
330 str.replace(f.rxUrl, f.replaceUrls);
331 // Push remaining non-URL part of the string to the queue...
332 if(f.replaceUrls.prevStart < str.length){
333 f.accum.push((f.replaceUrls.prevStart?' ':'')+str.substring(f.replaceUrls.prevStart));
334 }
335 // Pass 2: process @NAME references...
336 // TODO: only match NAME if it's the name of a currently participating
337 // user. Add a second class if NAME == current user, and style that one
338 // differently so that people can more easily see when they're spoken to.
339 const accum2 = f.replaceAtName.accum = [];
340 f.accum.forEach(function(v){
341 if('string'===typeof v){
342 f.rxAt.lastIndex = f.replaceAtName.prevStart = 0;
343 v.replace(f.rxAt, f.replaceAtName);
344 if(f.replaceAtName.prevStart < v.length){
345 accum2.push((f.replaceAtName.prevStart?' ':'')+v.substring(f.replaceAtName.prevStart));
346 }
347 }else{
348 accum2.push(v);
349 }
350 });
351 delete f.accum;
352 const theTgt = tgtElem || f.ce('div');
353 const strings = [];
354 accum2.forEach(function(e){
355 if('string'===typeof e) strings.push(e);
356 else strings.push(e.outerHTML);
357 });
358 D.parseHtml(theTgt, strings.join(''));
359 return theTgt;
360 }/*end messageToDOM()*/;
361
362 /** Callback for poll() to inject new content into the page. */
363 function newcontent(jx){
364 var i;
365 if('visible'===document.visibilityState){
366 if(Chat.changesSincePageHidden){
@@ -423,11 +347,20 @@
423 const br = D.br();
424 br.style.clear = "both";
425 eContent.appendChild(br);
426 }
427 if(m.xmsg){
428 messageToDOM(m.xmsg, eContent);
 
 
 
 
 
 
 
 
 
429 }
430 eContent.classList.add('chat-message');
431 }
432 }
433 async function poll(){
434
--- src/chat.js
+++ src/chat.js
@@ -281,86 +281,10 @@
281 x -= pRect.width/3*2;
282 }
283 f.popup.show(x, y);
284 };
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286 /** Callback for poll() to inject new content into the page. */
287 function newcontent(jx){
288 var i;
289 if('visible'===document.visibilityState){
290 if(Chat.changesSincePageHidden){
@@ -423,11 +347,20 @@
347 const br = D.br();
348 br.style.clear = "both";
349 eContent.appendChild(br);
350 }
351 if(m.xmsg){
352 // The m.xmsg text comes from the same server as this script and
353 // is guaranteed by that server to be "safe" HTML - safe in the
354 // sense that it is not possible for a malefactor to inject HTML
355 // or javascript or CSS. The m.xmsg content might contain
356 // hyperlinks, but otherwise it will be markup-free. See the
357 // chat_format_to_html() routine in the server for details.
358 //
359 // Hence, even though innerHTML is normally frowned upon, it is
360 // perfectly safe to use in this context.
361 eContent.innerHTML += m.xmsg
362 }
363 eContent.classList.add('chat-message');
364 }
365 }
366 async function poll(){
367
--- src/wikiformat.c
+++ src/wikiformat.c
@@ -32,10 +32,11 @@
3232
#define WIKI_NOBADLINKS 0x010 /* Ignore broken hyperlinks */
3333
#define WIKI_LINKSONLY 0x020 /* No markup. Only decorate links */
3434
#define WIKI_NEWLINE 0x040 /* Honor \n - break lines at each \n */
3535
#define WIKI_MARKDOWNLINKS 0x080 /* Resolve hyperlinks as in markdown */
3636
#define WIKI_SAFE 0x100 /* Make the result safe for embedding */
37
+#define WIKI_TARGET_BLANK 0x200 /* Hyperlinks go to a new window */
3738
#endif
3839
3940
4041
/*
4142
** These are the only markup attributes allowed.
@@ -1256,10 +1257,13 @@
12561257
const char *zExtraNS = 0;
12571258
char *zRemote = 0;
12581259
12591260
if( zTitle ){
12601261
zExtra = mprintf(" title='%h'", zTitle);
1262
+ zExtraNS = zExtra+1;
1263
+ }else if( mFlags & WIKI_TARGET_BLANK ){
1264
+ zExtra = mprintf(" target='_blank'");
12611265
zExtraNS = zExtra+1;
12621266
}
12631267
assert( nClose>=20 );
12641268
if( strncmp(zTarget, "http:", 5)==0
12651269
|| strncmp(zTarget, "https:", 6)==0
12661270
--- src/wikiformat.c
+++ src/wikiformat.c
@@ -32,10 +32,11 @@
32 #define WIKI_NOBADLINKS 0x010 /* Ignore broken hyperlinks */
33 #define WIKI_LINKSONLY 0x020 /* No markup. Only decorate links */
34 #define WIKI_NEWLINE 0x040 /* Honor \n - break lines at each \n */
35 #define WIKI_MARKDOWNLINKS 0x080 /* Resolve hyperlinks as in markdown */
36 #define WIKI_SAFE 0x100 /* Make the result safe for embedding */
 
37 #endif
38
39
40 /*
41 ** These are the only markup attributes allowed.
@@ -1256,10 +1257,13 @@
1256 const char *zExtraNS = 0;
1257 char *zRemote = 0;
1258
1259 if( zTitle ){
1260 zExtra = mprintf(" title='%h'", zTitle);
 
 
 
1261 zExtraNS = zExtra+1;
1262 }
1263 assert( nClose>=20 );
1264 if( strncmp(zTarget, "http:", 5)==0
1265 || strncmp(zTarget, "https:", 6)==0
1266
--- src/wikiformat.c
+++ src/wikiformat.c
@@ -32,10 +32,11 @@
32 #define WIKI_NOBADLINKS 0x010 /* Ignore broken hyperlinks */
33 #define WIKI_LINKSONLY 0x020 /* No markup. Only decorate links */
34 #define WIKI_NEWLINE 0x040 /* Honor \n - break lines at each \n */
35 #define WIKI_MARKDOWNLINKS 0x080 /* Resolve hyperlinks as in markdown */
36 #define WIKI_SAFE 0x100 /* Make the result safe for embedding */
37 #define WIKI_TARGET_BLANK 0x200 /* Hyperlinks go to a new window */
38 #endif
39
40
41 /*
42 ** These are the only markup attributes allowed.
@@ -1256,10 +1257,13 @@
1257 const char *zExtraNS = 0;
1258 char *zRemote = 0;
1259
1260 if( zTitle ){
1261 zExtra = mprintf(" title='%h'", zTitle);
1262 zExtraNS = zExtra+1;
1263 }else if( mFlags & WIKI_TARGET_BLANK ){
1264 zExtra = mprintf(" target='_blank'");
1265 zExtraNS = zExtra+1;
1266 }
1267 assert( nClose>=20 );
1268 if( strncmp(zTarget, "http:", 5)==0
1269 || strncmp(zTarget, "https:", 6)==0
1270

Keyboard Shortcuts

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