2.89 format.c
  1/* snac - A simple, minimalistic ActivityPub instance */
  2/* copyright (c) 2022 - 2026 grunfink et al. / MIT license */
  3
  4#include "xs.h"
  5#include "xs_regex.h"
  6#include "xs_mime.h"
  7#include "xs_html.h"
  8#include "xs_json.h"
  9#include "xs_time.h"
 10#include "xs_match.h"
 11#include "xs_unicode.h"
 12
 13#include "snac.h"
 14
 15/* emoticons, people laughing and such */
 16const char * const smileys[] = {
 17    ":-)",        "🙂",
 18    ":-D",        "😀",
 19    "X-D",        "😆",
 20    ";-)",        "😉",
 21    "B-)",        "😎",
 22    ">:-(",       "😡",
 23    ":-(",        "😞",
 24    ":-*",        "😘",
 25    ":-/",        "😕",
 26    "8-o",        "😲",
 27    "%-)",        "🤪",
 28    ":_(",        "😢",
 29    ":-|",        "😐",
 30    "<3",         "&#10084;&#65039;",
 31    ":facepalm:", "&#129318;",
 32    ":shrug:",    "&#129335;",
 33    ":shrug2:",   "&#175;\\_(&#12484;)_/&#175;",
 34    ":eyeroll:",  "&#128580;",
 35    ":beer:",     "&#127866;",
 36    ":beers:",    "&#127867;",
 37    ":munch:",    "&#128561;",
 38    ":thumb:",    "&#128077;",
 39    NULL,         NULL
 40};
 41
 42
 43xs_dict *emojis(void)
 44/* returns a dict with the emojis */
 45{
 46    xs *fn = xs_fmt("%s/emojis.json", srv_basedir);
 47    FILE *f;
 48
 49    if (mtime(fn) == 0) {
 50        /* file does not exist; create it with the defaults */
 51        xs *d = xs_dict_new();
 52        const char * const *emo = smileys;
 53
 54        while (*emo) {
 55            d = xs_dict_append(d, emo[0], emo[1]);
 56            emo += 2;
 57        }
 58
 59        if ((f = fopen(fn, "w")) != NULL) {
 60            xs_json_dump(d, 4, f);
 61            fclose(f);
 62        }
 63        else
 64            srv_log(xs_fmt("Error creating '%s'", fn));
 65    }
 66
 67    xs_dict *d = NULL;
 68
 69    if ((f = fopen(fn, "r")) != NULL) {
 70        d = xs_json_load(f);
 71        fclose(f);
 72
 73        if (d == NULL)
 74            srv_log(xs_fmt("JSON parse error in '%s'", fn));
 75    }
 76    else
 77        srv_log(xs_fmt("Error opening '%s'", fn));
 78
 79    return d;
 80}
 81
 82
 83xs_dict *emojis_rm_categories() {
 84    xs *emjs = emojis();
 85    char *res = xs_dict_new();
 86    const char *k, *v;
 87    xs_dict_foreach(emjs, k, v) {
 88        if (xs_type(v) == XSTYPE_DICT) {
 89            const char *v2;
 90            xs_dict_foreach(v, k, v2)
 91                res = xs_dict_append(res, k, v2);
 92        }
 93        else
 94            res = xs_dict_append(res, k, v);
 95    }
 96    return res;
 97}
 98
 99
100/* Non-whitespace without trailing comma, period or closing paren */
101#define NOSPACE "([^[:space:],.)]+|[,.)]+[^[:space:],.)])+"
102
103static xs_str *format_line(const char *line, xs_list **attach)
104/* formats a line */
105{
106    xs_str *s = xs_str_new(NULL);
107    char *p;
108    const char *v;
109
110    /* split by markup */
111    xs *sm = xs_regex_split(line,
112        "("
113            "`[^`]+`"                           "|"
114            "~~[^~]+~~"                         "|"
115            "\\*\\*?\\*?[^\\*]+\\*?\\*?\\*"     "|"
116            "__[^_]+__"                         "|" //anzu
117            "!\\[[^]]+\\]\\([^\\)]+\\)\\)?"     "|"
118            "\\[[^]]+\\]\\([^\\)]+\\)\\)?"      "|"
119            "[a-z]+:/" "/" NOSPACE              "|"
120            "(mailto|xmpp):[^@[:space:]]+@" NOSPACE
121        ")");
122    int n = 0;
123
124    p = sm;
125    while (xs_list_iter(&p, &v)) {
126        if ((n & 0x1)) {
127            /* markup */
128            if (xs_startswith(v, "`")) {
129                xs *s1 = xs_strip_chars_i(xs_dup(v), "`");
130                xs *e1 = encode_html(s1);
131                xs *s2 = xs_fmt("<code>%s</code>", e1);
132                s = xs_str_cat(s, s2);
133            }
134            else
135            if (xs_startswith(v, "***")) {
136                xs *s1 = xs_strip_chars_i(xs_dup(v), "*");
137                xs *s2 = xs_fmt("<b><i>%s</i></b>", s1);
138                s = xs_str_cat(s, s2);
139            }
140            else
141            if (xs_startswith(v, "**")) {
142                xs *s1 = xs_strip_chars_i(xs_dup(v), "*");
143                xs *s2 = xs_fmt("<b>%s</b>", s1);
144                s = xs_str_cat(s, s2);
145            }
146            else
147            if (xs_startswith(v, "*")) {
148                xs *s1 = xs_strip_chars_i(xs_dup(v), "*");
149                xs *s2 = xs_fmt("<i>%s</i>", s1);
150                s = xs_str_cat(s, s2);
151            }
152            //anzu - begin
153            else
154            if (xs_startswith(v, "__")) {
155                xs *s1 = xs_strip_chars_i(xs_dup(v), "_");
156                xs *s2 = xs_fmt("<u>%s</u>", s1);
157                s = xs_str_cat(s, s2);
158            }
159            //anzu - end
160            else
161            if (xs_startswith(v, "~~")) {
162                xs *s1 = xs_strip_chars_i(xs_dup(v), "~");
163                xs *e1 = encode_html(s1);
164                xs *s2 = xs_fmt("<s>%s</s>", e1);
165                s = xs_str_cat(s, s2);
166            }
167            else
168            if (*v == '[') {
169                /* markdown-like links [label](url) */
170                xs *w = xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;");
171                xs *l = xs_split_n(w, "](", 1);
172
173                if (xs_list_len(l) == 2) {
174                    xs *name = xs_dup(xs_list_get(l, 0));
175                    xs *url  = xs_dup(xs_list_get(l, 1));
176
177                    name = xs_crop_i(name, 1, 0);
178                    url  = xs_crop_i(url, 0, -1);
179
180                    xs *link = xs_fmt("<a href=\"%s\">%s</a>", url, name);
181
182                    s = xs_str_cat(s, link);
183                }
184                else
185                    s = xs_str_cat(s, v);
186            }
187            else
188            if (*v == '!') {
189                /* markdown-like images ![alt text](url to image) */
190                xs *w = xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;");
191                xs *l = xs_split_n(w, "](", 1);
192
193                if (xs_list_len(l) == 2) {
194                    xs *alt_text = xs_dup(xs_list_get(l, 0));
195                    xs *img_url  = xs_dup(xs_list_get(l, 1));
196
197                    alt_text = xs_crop_i(alt_text, 2, 0);
198                    img_url  = xs_crop_i(img_url, 0, -1);
199
200                    const char *mime = xs_mime_by_ext(img_url);
201
202                    if (attach != NULL && xs_startswith(mime, "image/")) {
203                        const xs_dict *ad;
204                        int add = 1;
205
206                        xs_list_foreach(*attach, ad) {
207                            if (strcmp(xs_dict_get_def(ad, "url", ""), img_url) == 0) {
208                                add = 0;
209                                break;
210                            }
211                        }
212
213                        if (add) {
214                            xs *d = xs_dict_new();
215
216                            d = xs_dict_append(d, "mediaType", mime);
217                            d = xs_dict_append(d, "url",       img_url);
218                            d = xs_dict_append(d, "name",      alt_text);
219                            d = xs_dict_append(d, "type",      "Image");
220
221                            *attach = xs_list_append(*attach, d);
222                        }
223                    }
224                    else {
225                        xs *link = xs_fmt("<a href=\"%s\">%s</a>", img_url, alt_text);
226
227                        s = xs_str_cat(s, link);
228                    }
229                }
230                else
231                    s = xs_str_cat(s, v);
232            }
233            else
234            if (xs_str_in(v, ":/" "/") != -1) {
235                /* direct URLs in the post body */
236                xs *u  = xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;");
237
238                xs *v2 = xs_strip_chars_i(xs_dup(u), ".,)");
239
240                const char *mime = xs_mime_by_ext(v2);
241
242                if (attach != NULL && xs_startswith(mime, "image/")) {
243                    /* if it's a link to an image, insert it as an attachment */
244                    const xs_dict *ad;
245                    int add = 1;
246
247                    xs_list_foreach(*attach, ad) {
248                        if (strcmp(xs_dict_get_def(ad, "url", ""), v2) == 0) {
249                            add = 0;
250                            break;
251                        }
252                    }
253
254                    if (add) {
255                        xs *d = xs_dict_new();
256
257                        d = xs_dict_append(d, "mediaType", mime);
258                        d = xs_dict_append(d, "url",       v2);
259                        d = xs_dict_append(d, "name",      "");
260                        d = xs_dict_append(d, "type",      "Image");
261
262                        *attach = xs_list_append(*attach, d);
263                    }
264                }
265                else {
266                    xs *s1 = xs_fmt("<a href=\"%s\" target=\"_blank\">%s</a>", v2, u);
267                    s = xs_str_cat(s, s1);
268                }
269            }
270            else
271            if (xs_match(v, "mailto*|xmpp*")) {
272                xs *u  = xs_replace_i(xs_replace(v, "#", "&#35;"), "@", "&#64;");
273
274                xs *v2 = xs_strip_chars_i(xs_dup(u), ".,)");
275
276                xs *s1 = xs_fmt("<a href=\"%s\" target=\"_blank\">%s</a>", v2, u);
277                s = xs_str_cat(s, s1);
278            }
279            else
280                s = xs_str_cat(s, v);
281        }
282        else
283            /* surrounded text, copy directly */
284            s = xs_str_cat(s, v);
285
286        n++;
287    }
288
289    return s;
290}
291
292
293xs_str *not_really_markdown(const char *content, xs_list **attach, xs_list **tag)
294/* formats a content using some Markdown rules */
295{
296    xs_str *s  = xs_str_new(NULL);
297    int in_pre = 0;
298    int in_blq = 0;
299    int in_ul = 0;
300    xs *list;
301    char *p;
302    const char *v;
303
304    /* work by lines */
305    list = xs_split(content, "\n");
306
307    p = list;
308    while (xs_list_iter(&p, &v)) {
309        xs *ss = NULL;
310
311        if (strcmp(v, "```") == 0) {
312            if (!in_pre)
313                s = xs_str_cat(s, "<pre>");
314            else
315                s = xs_str_cat(s, "</pre>");
316
317            in_pre = !in_pre;
318            continue;
319        }
320
321        if (in_pre) {
322            // Encode all HTML characters when we're in pre element until we are out.
323            ss = encode_html(v);
324
325            s = xs_str_cat(s, ss);
326            s = xs_str_cat(s, "<br>");
327            continue;
328        }
329
330        else
331            ss = xs_strip_i(format_line(v, attach));
332
333        if (xs_startswith(ss, "---")) {
334            /* delete the --- */
335            ss = xs_strip_i(xs_crop_i(ss, 3, 0));
336            s = xs_str_cat(s, "<hr>");
337
338            s = xs_str_cat(s, ss);
339
340            continue;
341        }
342
343        //anzu - begin
344        // h1 reserved for snac?
345        if (xs_startswith(ss, "# ")) {
346            ss = xs_strip_i(xs_crop_i(ss, 2, 0));
347            s = xs_str_cat(s, "<h2>");
348            s = xs_str_cat(s, ss);
349            s = xs_str_cat(s, "</h2>");
350            continue;
351        }
352        if (xs_startswith(ss, "## ")) {
353            ss = xs_strip_i(xs_crop_i(ss, 3, 0));
354            s = xs_str_cat(s, "<h2>");
355            s = xs_str_cat(s, ss);
356            s = xs_str_cat(s, "</h2>");
357            continue;
358        }
359        if (xs_startswith(ss, "### ")) {
360            ss = xs_strip_i(xs_crop_i(ss, 4, 0));
361            s = xs_str_cat(s, "<h3>");
362            s = xs_str_cat(s, ss);
363            s = xs_str_cat(s, "</h3>");
364            continue;
365        }
366        //anzu - end
367
368        if (xs_startswith(ss, ">")) {
369            /* delete the > and subsequent spaces */
370            ss = xs_strip_i(xs_crop_i(ss, 1, 0));
371
372            if (!in_blq) {
373                s = xs_str_cat(s, "<blockquote>");
374                in_blq = 1;
375            }
376
377            s = xs_str_cat(s, ss);
378            s = xs_str_cat(s, "<br>");
379
380            continue;
381        }
382
383        if (xs_startswith(ss, "* ") || xs_startswith(ss, "- ")) {
384            /* unsorted list */
385            ss = xs_strip_i(xs_crop_i(ss, 1, 0));
386
387            if (!in_ul) {
388                s = xs_str_cat(s, "<ul>");
389                in_ul = 1;
390            }
391
392            s = xs_str_cat(s, "<li>", ss);
393
394            continue;
395        }
396
397        if (in_blq) {
398            s = xs_str_cat(s, "</blockquote>");
399            in_blq = 0;
400        }
401
402        if (in_ul) {
403            s = xs_str_cat(s, "</ul>");
404            in_ul = 0;
405        }
406
407        s = xs_str_cat(s, ss);
408        s = xs_str_cat(s, "<br>");
409    }
410
411    if (in_blq)
412        s = xs_str_cat(s, "</blockquote>");
413    if (in_pre)
414        s = xs_str_cat(s, "</pre>");
415
416    /* some beauty fixes */
417    s = xs_replace_i(s, "<br><br><blockquote>", "<br><blockquote>");
418    s = xs_replace_i(s, "</blockquote><br>", "</blockquote>");
419    s = xs_replace_i(s, "</pre><br>", "</pre>");
420    s = xs_replace_i(s, "</ul><br>", "</ul>");
421    s = xs_replace_i(s, "</h2><br>", "</h2>"); //anzu ???
422    s = xs_replace_i(s, "</h3><br>", "</h3>"); //anzu ???
423
424    {
425        /* traditional emoticons */
426        xs *d = emojis_rm_categories();
427        int c = 0;
428        const char *k, *v;
429
430        while (xs_dict_next(d, &k, &v, &c)) {
431            const char *t = xs_mime_by_ext(v);
432
433            /* is it an URL to an image? */
434            if (xs_startswith(v, "https:/" "/") &&
435                (xs_startswith(t, "image/") || strcmp(t, "application/octet-stream") == 0)) {
436                if (tag && xs_str_in(s, k) != -1) {
437                    /* add the emoji to the tag list */
438                    xs *e = xs_dict_new();
439                    xs *i = xs_dict_new();
440                    xs *u = xs_str_utctime(0, ISO_DATE_SPEC);
441
442                    e = xs_dict_append(e, "id", v);
443                    e = xs_dict_append(e, "type", "Emoji");
444                    e = xs_dict_append(e, "name", k);
445                    e = xs_dict_append(e, "updated", u);
446
447                    i = xs_dict_append(i, "type", "Image");
448                    i = xs_dict_append(i, "mediaType", t);
449                    i = xs_dict_append(i, "url", v);
450                    e = xs_dict_append(e, "icon", i);
451
452                    *tag = xs_list_append(*tag, e);
453                }
454            }
455            else
456                s = xs_replace_i(s, k, v);
457        }
458    }
459
460    return s;
461}
462
463
464const char * const valid_tags[] = {
465    "a", "p", "br", "br/", "blockquote", "ul", "ol", "li", "cite", "small",
466    "span", "i", "b", "u", "s", "pre", "code", "em", "strong", "hr", "img", "del", "bdi",
467    "h2","h3", //anzu
468    NULL
469};
470
471xs_str *sanitize(const char *content)
472/* cleans dangerous HTML output */
473{
474    xs_str *s = xs_str_new(NULL);
475    xs *sl;
476    int n = 0;
477    char *p;
478    const char *v;
479
480    if (!content)
481        return NULL;
482
483    sl = xs_regex_split(content, "</?[^>]+>");
484
485    p = sl;
486
487    n = 0;
488    while (xs_list_iter(&p, &v)) {
489        if (n & 0x1) {
490            xs *s1  = xs_strip_i(xs_crop_i(xs_dup(v), v[1] == '/' ? 2 : 1, -1));
491            xs *l1  = xs_split_n(s1, " ", 1);
492            xs *tag = xs_utf8_to_lower(xs_list_get(l1, 0));
493            xs *s2  = NULL;
494            int i;
495
496            /* check if it's one of the valid tags */
497            for (i = 0; valid_tags[i]; i++) {
498                if (strcmp(tag, valid_tags[i]) == 0)
499                    break;
500            }
501
502            if (valid_tags[i]) {
503                /* accepted tag: rebuild it with only the accepted elements */
504                xs *el = xs_regex_select(v, "(src|href|rel|class|target)=(\"[^\"]*\"|'[^']*')");
505                xs *s3 = xs_join(el, " ");
506
507                s2 = xs_fmt("<%s%s%s%s>",
508                    v[1] == '/' ? "/" : "", tag, xs_list_len(el) ? " " : "", s3);
509
510                s = xs_str_cat(s, s2);
511            } else {
512                /* treat end of divs as paragraph breaks */
513                if (strcmp(v, "</div>"))
514                    s = xs_str_cat(s, "<p>");
515            }
516        }
517        else {
518            /* non-tag */
519            s = xs_str_cat(s, v);
520        }
521
522        n++;
523    }
524
525    return s;
526}
527
528
529xs_str *encode_html(const char *str)
530/* escapes html characters */
531{
532    xs_str *encoded = xs_html_encode((char *)str);
533
534    /* Restore only <br>. Probably safe. Let's hope nothing goes wrong with this. */
535    encoded = xs_replace_i(encoded, "&lt;br&gt;", "<br>");
536
537    return encoded;
538}