snac2

Fork of https://codeberg.org/grunfink/snac2
git clone https://git.inz.fi/snac2
Log | Files | Refs | README | LICENSE

html.c (183538B)


      1 /* snac - A simple, minimalistic ActivityPub instance */
      2 /* copyright (c) 2022 - 2025 grunfink et al. / MIT license */
      3 
      4 #include "xs.h"
      5 #include "xs_io.h"
      6 #include "xs_json.h"
      7 #include "xs_regex.h"
      8 #include "xs_set.h"
      9 #include "xs_openssl.h"
     10 #include "xs_time.h"
     11 #include "xs_mime.h"
     12 #include "xs_match.h"
     13 #include "xs_html.h"
     14 #include "xs_curl.h"
     15 #include "xs_unicode.h"
     16 #include "xs_url.h"
     17 #include "xs_random.h"
     18 
     19 #include "snac.h"
     20 #include <sys/mman.h>
     21 
     22 int login(snac *user, const xs_dict *headers)
     23 /* tries a login */
     24 {
     25     int logged_in = 0;
     26     const char *auth = xs_dict_get(headers, "authorization");
     27 
     28     if (auth && xs_startswith(auth, "Basic ")) {
     29         int sz;
     30         xs *s1 = xs_crop_i(xs_dup(auth), 6, 0);
     31         xs *s2 = xs_base64_dec(s1, &sz);
     32 
     33         xs *l1 = xs_split_n(s2, ":", 1);
     34 
     35         if (xs_list_len(l1) == 2) {
     36             const char *uid  = xs_list_get(l1, 0);
     37             const char *pwd  = xs_list_get(l1, 1);
     38             const char *addr = xs_or(xs_dict_get(headers, "remote-addr"),
     39                                      xs_dict_get(headers, "x-forwarded-for"));
     40 
     41             if (badlogin_check(uid, addr)) {
     42                 logged_in = check_password(uid, pwd,
     43                     xs_dict_get(user->config, "passwd"));
     44 
     45                 if (!logged_in)
     46                     badlogin_inc(uid, addr);
     47             }
     48         }
     49     }
     50 
     51     if (logged_in)
     52         lastlog_write(user, "web");
     53 
     54     return logged_in;
     55 }
     56 
     57 
     58 xs_str *replace_shortnames(xs_str *s, const xs_list *tag, int ems, const char *proxy)
     59 /* replaces all the :shortnames: with the emojis in tag */
     60 {
     61     if (!xs_is_null(tag)) {
     62         xs *tag_list = NULL;
     63         if (xs_type(tag) == XSTYPE_DICT) {
     64             /* not a list */
     65             tag_list = xs_list_new();
     66             tag_list = xs_list_append(tag_list, tag);
     67         } else {
     68             /* is a list */
     69             tag_list = xs_dup(tag);
     70         }
     71 
     72         xs *style = xs_fmt("height: %dem; width: %dem; vertical-align: middle;", ems, ems);
     73         xs *class = xs_fmt("snac-emoji snac-emoji-%d-em", ems);
     74 
     75         const xs_dict *v;
     76         int c = 0;
     77 
     78         xs_set rep_emoji;
     79         xs_set_init(&rep_emoji);
     80 
     81         while (xs_list_next(tag_list, &v, &c)) {
     82             const char *t = xs_dict_get(v, "type");
     83 
     84             if (t && strcmp(t, "Emoji") == 0) {
     85                 const char *n = xs_dict_get(v, "name");
     86                 const xs_dict *i = xs_dict_get(v, "icon");
     87 
     88                 /* avoid repeated emojis (Misskey seems to return this) */
     89                 if (xs_set_add(&rep_emoji, n) == 0)
     90                     continue;
     91 
     92                 if (xs_is_string(n) && xs_is_dict(i)) {
     93                     const char *u = xs_dict_get(i, "url");
     94                     const char *mt = xs_dict_get(i, "mediaType");
     95 
     96                     if (xs_is_string(u)) {
     97                         // on akkoma instances mediaType is not present.
     98                         // but we need to to know if the image is an svg or not.
     99                         // for now, i just use the file extention, which may not be the most reliable...
    100                         if (!xs_is_string(mt))
    101                             mt = xs_mime_by_ext(u);
    102 
    103                         if (strcmp(mt, "image/svg+xml") == 0 && !xs_is_true(xs_dict_get(srv_config, "enable_svg")))
    104                             s = xs_replace_i(s, n, "");
    105                         else {
    106                             xs *url = make_url(u, proxy, 0);
    107 
    108                             xs_html *img = xs_html_sctag("img",
    109                                 xs_html_attr("loading", "lazy"),
    110                                 xs_html_attr("src", url),
    111                                 xs_html_attr("alt", n),
    112                                 xs_html_attr("title", n),
    113                                 xs_html_attr("class", class),
    114                                 xs_html_attr("style", style));
    115 
    116                             xs *s1 = xs_html_render(img);
    117                             s = xs_replace_i(s, n, s1);
    118                         }
    119                     }
    120                     else
    121                         s = xs_replace_i(s, n, "");
    122                 }
    123             }
    124         }
    125 
    126         xs_set_free(&rep_emoji);
    127     }
    128 
    129     return s;
    130 }
    131 
    132 
    133 xs_str *actor_name(xs_dict *actor, const char *proxy)
    134 /* gets the actor name */
    135 {
    136     const char *v;
    137 
    138     if (xs_is_null((v = xs_dict_get(actor, "name"))) || *v == '\0') {
    139         if (xs_is_null(v = xs_dict_get(actor, "preferredUsername")) || *v == '\0') {
    140             v = "anonymous";
    141         }
    142     }
    143 
    144     return replace_shortnames(xs_html_encode(v), xs_dict_get(actor, "tag"), 1, proxy);
    145 }
    146 
    147 
    148 xs_str *format_text_with_emoji(snac *user, const char *text, int ems, const char *proxy)
    149 /* needed when we have local text with no tags attached */
    150 {
    151     xs *tags = xs_list_new();
    152     xs *name1 = not_really_markdown(text, NULL, &tags);
    153 
    154     xs_str *name3;
    155     if (user) {
    156         xs *name2 = process_tags(user, name1, &tags);
    157         name3 = sanitize(name2);
    158     }
    159     else {
    160         name3 = sanitize(name1);
    161         name3 = xs_replace_i(name3, "<br>", "");
    162     }
    163 
    164     return replace_shortnames(name3, tags, ems, proxy);
    165 }
    166 
    167 
    168 xs_html *html_actor_icon(snac *user, xs_dict *actor, const char *date,
    169                         const char *udate, const char *url, int priv,
    170                         int in_people, const char *proxy, const char *lang,
    171                         const char *md5)
    172 {
    173     xs_html *actor_icon = xs_html_tag("p", NULL);
    174 
    175     xs *avatar = NULL;
    176     const char *v;
    177     int fwing = 0;
    178     int fwer = 0;
    179 
    180     xs *name = actor_name(actor, proxy);
    181 
    182     /* get the avatar */
    183     if ((v = xs_dict_get(actor, "icon")) != NULL) {
    184         /* if it's a list (Peertube), get the first one */
    185         if (xs_type(v) == XSTYPE_LIST)
    186             v = xs_list_get(v, 0);
    187 
    188         if ((v = xs_dict_get(v, "url")) != NULL)
    189             avatar = make_url(v, proxy, 0);
    190     }
    191 
    192     if (avatar == NULL)
    193         avatar = xs_fmt("data:image/png;base64, %s", default_avatar_base64());
    194 
    195     const char *actor_id = xs_dict_get(actor, "id");
    196     xs *href = NULL;
    197 
    198     if (user) {
    199         fwer = follower_check(user, actor_id);
    200         fwing = following_check(user, actor_id);
    201     }
    202 
    203     if (user && !in_people) {
    204         /* if this actor is a follower or being followed, create an
    205            anchored link to the people page instead of the actor url */
    206         if (fwer || fwing) {
    207             const char *md5 = xs_md5(actor_id);
    208             href = xs_fmt("%s/people#%s", user->actor, md5);
    209         }
    210     }
    211 
    212     if (href == NULL)
    213         href = xs_dup(actor_id);
    214 
    215     xs_html_add(actor_icon,
    216         xs_html_sctag("img",
    217             xs_html_attr("loading", "lazy"),
    218             xs_html_attr("class",   "snac-avatar"),
    219             xs_html_attr("src",     avatar),
    220             xs_html_attr("alt",     "[?]")),
    221         xs_html_tag("a",
    222             xs_html_attr("href",    href),
    223             xs_html_attr("class",   "p-author h-card snac-author"),
    224             xs_html_raw(name))); /* name is already html-escaped */
    225 
    226     if (!xs_is_null(url)) {
    227         const char *md5 = xs_md5(url);
    228 
    229         xs_html_add(actor_icon,
    230             xs_html_text(" "),
    231             xs_html_tag("a",
    232                 xs_html_attr("href", (char *)url),
    233                 xs_html_attr("title", md5),
    234                 xs_html_text("ยป")));
    235     }
    236 
    237     if (strcmp(xs_dict_get(actor, "type"), "Service") == 0) {
    238         xs_html_add(actor_icon,
    239             xs_html_text(" "),
    240             xs_html_tag("span",
    241                 xs_html_attr("title", "bot"),
    242                 xs_html_raw("&#129302;")));
    243     }
    244 
    245     if (fwing && fwer) {
    246         xs_html_add(actor_icon,
    247             xs_html_text(" "),
    248             xs_html_tag("span",
    249                 xs_html_attr("title", "mutual relation"),
    250                 xs_html_raw("&#129309;")));
    251     }
    252 
    253     if (priv) {
    254         xs_html_add(actor_icon,
    255             xs_html_text(" "),
    256             xs_html_tag("span",
    257                 xs_html_attr("title", "private"),
    258                 xs_html_raw("&#128274;")));
    259     }
    260 
    261     if (xs_is_null(date)) {
    262         xs_html_add(actor_icon,
    263             xs_html_raw("&nbsp;"));
    264     }
    265     else {
    266         xs *date_label = xs_crop_i(xs_dup(date), 0, 10);
    267         xs *date_title = xs_dup(date);
    268 
    269         if (!xs_is_null(udate)) {
    270             xs *sd = xs_crop_i(xs_dup(udate), 0, 10);
    271 
    272             date_label = xs_str_cat(date_label, " / ", sd);
    273 
    274             date_title = xs_str_cat(date_title, " / ", udate);
    275         }
    276 
    277         if (xs_is_string(lang))
    278             date_title = xs_str_cat(date_title, " (", lang, ")");
    279 
    280         xs_html *date_text = xs_html_text(date_label);
    281 
    282         if (user && md5) {
    283             xs *lpost_url = xs_fmt("%s/admin/p/%s#%s_entry",
    284                                    user->actor, md5, md5);
    285             date_text = xs_html_tag("a",
    286                                     xs_html_attr("href", lpost_url),
    287                                     xs_html_attr("class", "snac-pubdate"),
    288                                     date_text);
    289         }
    290         else if (user && url) {
    291             xs *lpost_url = xs_fmt("%s/admin?q=%s",
    292                                    user->actor, xs_url_enc(url));
    293             date_text = xs_html_tag("a",
    294                                     xs_html_attr("href", lpost_url),
    295                                     xs_html_attr("class", "snac-pubdate"),
    296                                     date_text);
    297         }
    298 
    299         xs_html_add(actor_icon,
    300             xs_html_text(" "),
    301             xs_html_tag("time",
    302                 xs_html_attr("class", "dt-published snac-pubdate"),
    303                 xs_html_attr("title", date_title),
    304                 date_text));
    305     }
    306 
    307     {
    308         const char *username, *id;
    309 
    310         if (xs_is_null(username = xs_dict_get(actor, "preferredUsername")) || *username == '\0') {
    311             /* This should never be reached */
    312             username = "anonymous";
    313         }
    314 
    315         if (xs_is_null(id = xs_dict_get(actor, "id")) || *id == '\0') {
    316             /* This should never be reached */
    317             id = "https://social.example.org/anonymous";
    318         }
    319 
    320         /* "LIKE AN ANIMAL" */
    321         xs *domain = xs_split(id, "/");
    322         xs *user   = xs_fmt("@%s@%s", username, xs_list_get(domain, 2));
    323 
    324         xs_html_add(actor_icon,
    325             xs_html_sctag("br", NULL),
    326             xs_html_tag("a",
    327                 xs_html_attr("href",  xs_dict_get(actor, "id")),
    328                 xs_html_attr("class", "p-author-tag h-card snac-author-tag"),
    329                 xs_html_text(user)));
    330     }
    331 
    332     return actor_icon;
    333 }
    334 
    335 
    336 xs_html *html_msg_icon(snac *user, const char *actor_id, const xs_dict *msg,
    337                        const char *proxy, const char *md5, const char *lang)
    338 {
    339     xs *actor = NULL;
    340     xs_html *actor_icon = NULL;
    341 
    342     if (actor_id && valid_status(actor_get_refresh(user, actor_id, &actor))) {
    343         const char *date  = NULL;
    344         const char *udate = NULL;
    345         const char *url   = NULL;
    346         int priv    = 0;
    347         const char *type = xs_dict_get(msg, "type");
    348 
    349         if (xs_match(type, POSTLIKE_OBJECT_TYPE))
    350             url = xs_dict_get(msg, "id");
    351 
    352         priv = !is_msg_public(msg);
    353 
    354         date  = xs_dict_get(msg, "published");
    355         udate = xs_dict_get(msg, "updated");
    356 
    357         actor_icon = html_actor_icon(user, actor, date, udate, url, priv, 0, proxy, lang, md5);
    358     }
    359 
    360     return actor_icon;
    361 }
    362 
    363 
    364 xs_html *html_note(snac *user, const char *summary,
    365                    const char *div_id, const char *form_id,
    366                    const char *ta_plh, const char *ta_content,
    367                    const char *edit_id, const char *actor_id,
    368                    const xs_val *cw_yn, const char *cw_text,
    369                    const xs_val *mnt_only, const char *redir,
    370                    const char *in_reply_to, int poll,
    371                    const xs_list *att_files, const xs_list *att_alt_texts,
    372                    int is_draft, const char *published)
    373 /* Yes, this is a FUCKTON of arguments and I'm a bit embarrased */
    374 {
    375     xs *action = xs_fmt("%s/admin/note", user->actor);
    376 
    377     xs_html *form;
    378 
    379     xs_html *note = xs_html_tag("div",
    380         xs_html_tag("details",
    381             xs_html_tag("summary",
    382                 xs_html_text(summary)),
    383                 xs_html_tag("p", NULL),
    384                 xs_html_tag("div",
    385                     xs_html_attr("class", "snac-note"),
    386                     xs_html_attr("id",    div_id),
    387                     form = xs_html_tag("form",
    388                         xs_html_attr("autocomplete", "off"),
    389                         xs_html_attr("method",       "post"),
    390                         xs_html_attr("action",       action),
    391                         xs_html_attr("enctype",      "multipart/form-data"),
    392                         xs_html_attr("id",           form_id),
    393                         xs_html_tag("textarea",
    394                             xs_html_attr("class",    "snac-textarea"),
    395                             xs_html_attr("name",     "content"),
    396                             xs_html_attr("rows",     "4"),
    397                             xs_html_attr("wrap",     "virtual"),
    398                             xs_html_attr("required", "required"),
    399                             xs_html_attr("placeholder", ta_plh),
    400                             xs_html_text(ta_content)),
    401                         xs_html_tag("p", NULL),
    402                         xs_html_text(L("Sensitive content: ")),
    403                         xs_html_sctag("input",
    404                             xs_html_attr("type", "checkbox"),
    405                             xs_html_attr("name", "sensitive"),
    406                             xs_html_attr(xs_type(cw_yn) == XSTYPE_TRUE ? "checked" : "", NULL)),
    407                         xs_html_sctag("input",
    408                             xs_html_attr("type", "text"),
    409                             xs_html_attr("name", "summary"),
    410                             xs_html_attr("placeholder", L("Sensitive content description")),
    411                             xs_html_attr("value", xs_is_null(cw_text) ? "" : cw_text))))));
    412 
    413     if (actor_id)
    414         xs_html_add(form,
    415             xs_html_sctag("input",
    416                 xs_html_attr("type",  "hidden"),
    417                 xs_html_attr("name",  "to"),
    418                 xs_html_attr("value", actor_id)));
    419     else {
    420         /* no actor_id; ask for mentioned_only */
    421         xs_html_add(form,
    422             xs_html_tag("p", NULL),
    423             xs_html_text(L("Only for mentioned people: ")),
    424             xs_html_sctag("input",
    425                 xs_html_attr("type",  "checkbox"),
    426                 xs_html_attr("name",  "mentioned_only"),
    427                 xs_html_attr(xs_type(mnt_only) == XSTYPE_TRUE ? "checked" : "", NULL)));
    428     }
    429 
    430     if (redir)
    431         xs_html_add(form,
    432             xs_html_sctag("input",
    433                 xs_html_attr("type",  "hidden"),
    434                 xs_html_attr("name",  "redir"),
    435                 xs_html_attr("value", redir)));
    436 
    437     if (in_reply_to)
    438         xs_html_add(form,
    439             xs_html_sctag("input",
    440                 xs_html_attr("type",  "hidden"),
    441                 xs_html_attr("name",  "in_reply_to"),
    442                 xs_html_attr("value", in_reply_to)));
    443     else
    444         xs_html_add(form,
    445             xs_html_tag("p", NULL),
    446             xs_html_text(L("Reply to (URL): ")),
    447             xs_html_sctag("input",
    448                 xs_html_attr("type",     "url"),
    449                 xs_html_attr("name",     "in_reply_to"),
    450                 xs_html_attr("placeholder", L("Optional URL to reply to"))));
    451 
    452     xs_html_add(form,
    453         xs_html_tag("p", NULL),
    454         xs_html_tag("span",
    455             xs_html_attr("title", L("Don't send, but store as a draft")),
    456             xs_html_text(L("Draft:")),
    457             xs_html_sctag("input",
    458                 xs_html_attr("type", "checkbox"),
    459                 xs_html_attr("name", "is_draft"),
    460                 xs_html_attr(is_draft ? "checked" : "", NULL))));
    461 
    462     /* post date and time */
    463     xs *post_date = NULL;
    464     xs *post_time = NULL;
    465 
    466     if (xs_is_string(published)) {
    467         time_t t = xs_parse_iso_date(published, 0);
    468 
    469         if (t > 0) {
    470             post_date = xs_str_time(t, "%Y-%m-%d", 1);
    471             post_time = xs_str_time(t, "%H:%M:%S", 1);
    472         }
    473     }
    474 
    475     if (edit_id == NULL || is_draft || is_scheduled(user, edit_id)) {
    476         xs *pdat = xs_fmt(L("Post date and time (timezone: %s):"), user->tz);
    477 
    478         xs_html_add(form,
    479             xs_html_tag("p",
    480                 xs_html_tag("details",
    481                     xs_html_tag("summary",
    482                         xs_html_text(L("Scheduled post..."))),
    483                     xs_html_tag("p",
    484                         xs_html_text(pdat),
    485                         xs_html_sctag("br", NULL),
    486                         xs_html_sctag("input",
    487                             xs_html_attr("type",  "date"),
    488                             xs_html_attr("value", post_date ? post_date : ""),
    489                             xs_html_attr("name",  "post_date")),
    490                         xs_html_text(" "),
    491                         xs_html_sctag("input",
    492                             xs_html_attr("type",  "time"),
    493                             xs_html_attr("value", post_time ? post_time : ""),
    494                             xs_html_attr("step",  "1"),
    495                             xs_html_attr("name",  "post_time"))))));
    496     }
    497 
    498     if (edit_id)
    499         xs_html_add(form,
    500             xs_html_sctag("input",
    501                 xs_html_attr("type",  "hidden"),
    502                 xs_html_attr("name",  "edit_id"),
    503                 xs_html_attr("value", edit_id)));
    504 
    505     /* attachment controls */
    506     xs_html *att;
    507 
    508     xs_html_add(form,
    509         xs_html_tag("p", NULL),
    510         att = xs_html_tag("details",
    511             xs_html_tag("summary",
    512                 xs_html_text(L("Attachments..."))),
    513             xs_html_tag("p", NULL)));
    514 
    515     int max_attachments = xs_number_get(xs_dict_get_def(srv_config, "max_attachments", "4"));
    516     int att_n = 0;
    517 
    518     /* fields for the currently existing attachments */
    519     if (xs_is_list(att_files) && xs_is_list(att_alt_texts)) {
    520         while (att_n < max_attachments) {
    521             const char *att_file = xs_list_get(att_files, att_n);
    522             const char *att_alt_text = xs_list_get(att_alt_texts, att_n);
    523 
    524             if (!xs_is_string(att_file) || !xs_is_string(att_alt_text))
    525                 break;
    526 
    527             xs *att_lbl = xs_fmt("attach_url_%d", att_n);
    528             xs *alt_lbl = xs_fmt("alt_text_%d", att_n);
    529 
    530             if (att_n)
    531                 xs_html_add(att,
    532                     xs_html_sctag("br", NULL));
    533 
    534             xs_html_add(att,
    535                 xs_html_text(L("File:")),
    536                 xs_html_sctag("input",
    537                     xs_html_attr("type", "text"),
    538                     xs_html_attr("name", att_lbl),
    539                     xs_html_attr("title", L("Clear this field to delete the attachment")),
    540                     xs_html_attr("value", att_file)));
    541 
    542             xs_html_add(att,
    543                 xs_html_text(" "),
    544                 xs_html_sctag("input",
    545                     xs_html_attr("type",    "text"),
    546                     xs_html_attr("name",    alt_lbl),
    547                     xs_html_attr("value",   att_alt_text),
    548                     xs_html_attr("placeholder", L("Attachment description"))));
    549 
    550             att_n++;
    551         }
    552     }
    553 
    554     /* the rest of possible attachments */
    555     while (att_n < max_attachments) {
    556         xs *att_lbl = xs_fmt("attach_%d", att_n);
    557         xs *alt_lbl = xs_fmt("alt_text_%d", att_n);
    558 
    559         if (att_n)
    560             xs_html_add(att,
    561                 xs_html_sctag("br", NULL));
    562 
    563         xs_html_add(att,
    564             xs_html_sctag("input",
    565                 xs_html_attr("type",    "file"),
    566                 xs_html_attr("name",    att_lbl)));
    567 
    568         xs_html_add(att,
    569             xs_html_text(" "),
    570             xs_html_sctag("input",
    571                 xs_html_attr("type",    "text"),
    572                 xs_html_attr("name",    alt_lbl),
    573                 xs_html_attr("placeholder", L("Attachment description"))));
    574 
    575         att_n++;
    576     }
    577 
    578     /* add poll controls */
    579     if (poll) {
    580         xs_html_add(form,
    581             xs_html_tag("p", NULL),
    582             xs_html_tag("details",
    583                 xs_html_tag("summary",
    584                     xs_html_text(L("Poll..."))),
    585                 xs_html_tag("p",
    586                     xs_html_text(L("Poll options (one per line, up to 8):")),
    587                     xs_html_sctag("br", NULL),
    588                     xs_html_tag("textarea",
    589                         xs_html_attr("class",    "snac-textarea"),
    590                         xs_html_attr("name",     "poll_options"),
    591                         xs_html_attr("rows",     "4"),
    592                         xs_html_attr("wrap",     "virtual"),
    593                         xs_html_attr("placeholder", L("Option 1...\nOption 2...\nOption 3...\n...")))),
    594                 xs_html_tag("select",
    595                     xs_html_attr("name",    "poll_multiple"),
    596                     xs_html_tag("option",
    597                         xs_html_attr("value", "off"),
    598                         xs_html_text(L("One choice"))),
    599                     xs_html_tag("option",
    600                         xs_html_attr("value", "on"),
    601                         xs_html_text(L("Multiple choices")))),
    602                 xs_html_text(" "),
    603                 xs_html_tag("select",
    604                     xs_html_attr("name",    "poll_end_secs"),
    605                     xs_html_tag("option",
    606                         xs_html_attr("value", "300"),
    607                         xs_html_text(L("End in 5 minutes"))),
    608                     xs_html_tag("option",
    609                         xs_html_attr("value", "3600"),
    610                         xs_html_attr("selected", NULL),
    611                         xs_html_text(L("End in 1 hour"))),
    612                     xs_html_tag("option",
    613                         xs_html_attr("value", "86400"),
    614                         xs_html_text(L("End in 1 day"))))));
    615     }
    616 
    617     xs_html_add(form,
    618         xs_html_tag("p", NULL),
    619         xs_html_sctag("input",
    620             xs_html_attr("type",     "submit"),
    621             xs_html_attr("class",    "button"),
    622             xs_html_attr("value",    L("Post"))),
    623         xs_html_tag("p", NULL));
    624 
    625     return note;
    626 }
    627 
    628 
    629 static xs_html *html_base_head(void)
    630 {
    631     xs_html *head = xs_html_tag("head",
    632         xs_html_sctag("meta",
    633             xs_html_attr("name", "viewport"),
    634             xs_html_attr("content", "width=device-width, initial-scale=1")),
    635         xs_html_sctag("meta",
    636             xs_html_attr("name", "generator"),
    637             xs_html_attr("content", USER_AGENT)));
    638 
    639     /* add server CSS and favicon */
    640     xs *f;
    641     f = xs_fmt("%s/favicon.ico", srv_baseurl);
    642     const xs_list *p = xs_dict_get(srv_config, "cssurls");
    643     const char *v;
    644     int c = 0;
    645 
    646     while (xs_list_next(p, &v, &c)) {
    647         xs_html_add(head,
    648             xs_html_sctag("link",
    649                 xs_html_attr("rel",  "stylesheet"),
    650                 xs_html_attr("type", "text/css"),
    651                 xs_html_attr("href", v)));
    652     }
    653 
    654     xs_html_add(head,
    655         xs_html_sctag("link",
    656             xs_html_attr("rel",  "icon"),
    657             xs_html_attr("type", "image/x-icon"),
    658             xs_html_attr("href", f)));
    659 
    660     return head;
    661 }
    662 
    663 
    664 xs_html *html_instance_head(void)
    665 {
    666     xs_html *head = html_base_head();
    667 
    668     {
    669         FILE *f;
    670         xs *g_css_fn = xs_fmt("%s/style.css", srv_basedir);
    671 
    672         if ((f = fopen(g_css_fn, "r")) != NULL) {
    673             xs *css = xs_readall(f);
    674             fclose(f);
    675 
    676             xs_html_add(head,
    677                 xs_html_tag("style",
    678                     xs_html_raw(css)));
    679         }
    680     }
    681 
    682     const char *host  = xs_dict_get(srv_config, "host");
    683     const char *title = xs_dict_get(srv_config, "title");
    684 
    685     xs_html_add(head,
    686         xs_html_tag("title",
    687             xs_html_text(title && *title ? title : host)));
    688 
    689     return head;
    690 }
    691 
    692 
    693 static xs_html *html_instance_body(void)
    694 {
    695     const char *host     = xs_dict_get(srv_config, "host");
    696     const char *sdesc    = xs_dict_get(srv_config, "short_description");
    697     const char *sdescraw = xs_dict_get(srv_config, "short_description_raw");
    698     const char *email    = xs_dict_get(srv_config, "admin_email");
    699     const char *acct     = xs_dict_get(srv_config, "admin_account");
    700 
    701     /* for L() */
    702     const snac *user = NULL;
    703 
    704     xs *blurb = xs_replace(snac_blurb, "%host%", host);
    705 
    706     xs_html *dl;
    707 
    708     xs_html *body = xs_html_tag("body",
    709         xs_html_tag("div",
    710             xs_html_attr("class", "snac-instance-blurb"),
    711             xs_html_raw(blurb), /* pure html */
    712             dl = xs_html_tag("dl", NULL)));
    713 
    714     if (sdesc && *sdesc) {
    715         if (!xs_is_null(sdescraw) && xs_type(sdescraw) == XSTYPE_TRUE) {
    716             xs_html_add(dl,
    717                 xs_html_tag("di",
    718                     xs_html_tag("dt",
    719                         xs_html_text(L("Site description"))),
    720                     xs_html_tag("dd",
    721                         xs_html_raw(sdesc))));
    722         } else {
    723             xs_html_add(dl,
    724                 xs_html_tag("di",
    725                     xs_html_tag("dt",
    726                         xs_html_text(L("Site description"))),
    727                     xs_html_tag("dd",
    728                         xs_html_text(sdesc))));
    729         }
    730     }
    731     if (email && *email) {
    732         xs *mailto = xs_fmt("mailto:%s", email);
    733 
    734         xs_html_add(dl,
    735             xs_html_tag("di",
    736                 xs_html_tag("dt",
    737                     xs_html_text(L("Admin email"))),
    738                 xs_html_tag("dd",
    739                     xs_html_tag("a",
    740                         xs_html_attr("href", mailto),
    741                         xs_html_text(email)))));
    742     }
    743     if (acct && *acct) {
    744         xs *url    = xs_fmt("%s/%s", srv_baseurl, acct);
    745         xs *handle = xs_fmt("@%s@%s", acct, host);
    746 
    747         xs_html_add(dl,
    748             xs_html_tag("di",
    749                 xs_html_tag("dt",
    750                     xs_html_text(L("Admin account"))),
    751                 xs_html_tag("dd",
    752                     xs_html_tag("a",
    753                         xs_html_attr("href", url),
    754                         xs_html_text(handle)))));
    755     }
    756 
    757     return body;
    758 }
    759 
    760 
    761 xs_html *html_user_head(snac *user, const char *desc, const char *url)
    762 {
    763     xs_html *head = html_base_head();
    764     int mmapped = 0;
    765 
    766     /* add the user CSS */
    767     {
    768         xs *css = NULL;
    769         int size;
    770 
    771         /* try to open the user css */
    772         if (!valid_status(static_get(user, "style.css", &css, &size, NULL, NULL, &mmapped))) {
    773             /* it's not there; try to open the server-wide css */
    774             FILE *f;
    775             xs *g_css_fn = xs_fmt("%s/style.css", srv_basedir);
    776 
    777             if ((f = fopen(g_css_fn, "r")) != NULL) {
    778                 css = xs_readall(f);
    779                 fclose(f);
    780             }
    781         }
    782 
    783         if (css != NULL) {
    784             xs_html_add(head,
    785                 xs_html_tag("style",
    786                     xs_html_raw(css)));
    787         }
    788 
    789         if (mmapped) {
    790             munmap(css, size);
    791             css = NULL;
    792         }
    793     }
    794 
    795     /* title */
    796     xs *title = xs_fmt("%s (@%s@%s)", xs_dict_get(user->config, "name"),
    797         user->uid, xs_dict_get(srv_config, "host"));
    798 
    799     xs_html_add(head,
    800         xs_html_tag("title",
    801             xs_html_text(title)));
    802 
    803     xs *avatar = xs_dup(xs_dict_get(user->config, "avatar"));
    804 
    805     if (avatar == NULL || *avatar == '\0') {
    806         xs_free(avatar);
    807         avatar = xs_fmt("%s/susie.png", srv_baseurl);
    808     }
    809 
    810     /* create a description field */
    811     xs *s_desc = NULL;
    812     int n;
    813 
    814     if (desc == NULL)
    815         s_desc = xs_dup(xs_dict_get(user->config, "bio"));
    816     else
    817         s_desc = xs_dup(desc);
    818 
    819     /* show metrics in og:description? */
    820     if (xs_is_true(xs_dict_get(user->config, "show_contact_metrics"))) {
    821         xs *s1 = xs_fmt(L("%d following, %d followers"), following_list_len(user), follower_list_len(user));
    822 
    823         s1 = xs_str_cat(s1, " ยท ");
    824 
    825         s_desc = xs_str_prepend_i(s_desc, s1);
    826     }
    827 
    828     /* shorten desc to a reasonable size */
    829     for (n = 0; s_desc[n]; n++) {
    830         if (n > 512 && (s_desc[n] == ' ' || s_desc[n] == '\n'))
    831             break;
    832     }
    833 
    834     s_desc[n] = '\0';
    835 
    836     /* og properties */
    837     xs_html_add(head,
    838         xs_html_sctag("meta",
    839             xs_html_attr("property", "og:site_name"),
    840             xs_html_attr("content", xs_dict_get(srv_config, "host"))),
    841         xs_html_sctag("meta",
    842             xs_html_attr("property", "og:title"),
    843             xs_html_attr("content", title)),
    844         xs_html_sctag("meta",
    845             xs_html_attr("property", "og:description"),
    846             xs_html_attr("content", s_desc)),
    847         xs_html_sctag("meta",
    848             xs_html_attr("property", "og:image"),
    849             xs_html_attr("content", avatar)),
    850         xs_html_sctag("meta",
    851             xs_html_attr("property", "og:width"),
    852             xs_html_attr("content", "300")),
    853         xs_html_sctag("meta",
    854             xs_html_attr("property", "og:height"),
    855             xs_html_attr("content", "300")));
    856 
    857     /* RSS link */
    858     xs *rss_url = xs_fmt("%s.rss", user->actor);
    859     xs_html_add(head,
    860         xs_html_sctag("link",
    861             xs_html_attr("rel", "alternate"),
    862             xs_html_attr("type", "application/rss+xml"),
    863             xs_html_attr("title", "RSS"),
    864             xs_html_attr("href", rss_url)));
    865 
    866     /* ActivityPub alternate link (actor id) */
    867     xs_html_add(head,
    868         xs_html_sctag("link",
    869             xs_html_attr("rel", "alternate"),
    870             xs_html_attr("type", "application/activity+json"),
    871             xs_html_attr("href", url ? url : user->actor)));
    872 
    873     return head;
    874 }
    875 
    876 
    877 static xs_html *html_user_body(snac *user, int read_only)
    878 {
    879     const char *proxy = NULL;
    880 
    881     if (user && !read_only && xs_is_true(xs_dict_get(srv_config, "proxy_media")))
    882         proxy = user->actor;
    883 
    884     xs_html *body = xs_html_tag("body", NULL);
    885 
    886     /* top nav */
    887     xs_html *top_nav = xs_html_tag("nav",
    888         xs_html_attr("class", "snac-top-nav"));
    889 
    890     xs *avatar = xs_dup(xs_dict_get(user->config, "avatar"));
    891 
    892     if (avatar == NULL || *avatar == '\0') {
    893         xs_free(avatar);
    894         avatar = xs_fmt("data:image/png;base64, %s", default_avatar_base64());
    895     }
    896 
    897     xs_html_add(top_nav,
    898         xs_html_sctag("img",
    899             xs_html_attr("src", avatar),
    900             xs_html_attr("class", "snac-avatar"),
    901             xs_html_attr("alt", "")));
    902 
    903     if (read_only) {
    904         xs *rss_url = xs_fmt("%s.rss", user->actor);
    905         xs *admin_url = xs_fmt("%s/admin", user->actor);
    906 
    907         xs_html_add(top_nav,
    908             xs_html_tag("a",
    909                 xs_html_attr("href", rss_url),
    910                 xs_html_attr("id", "rss"),
    911                 xs_html_text(L("RSS"))),
    912             xs_html_text(" - "),
    913             xs_html_tag("a",
    914                 xs_html_attr("href", admin_url),
    915                 xs_html_attr("id", "private"),
    916                 xs_html_attr("rel", "nofollow"),
    917                 xs_html_text(L("private"))));
    918     }
    919     else {
    920         int n_len = notify_new_num(user);
    921         int p_len = pending_count(user);
    922         xs_html *notify_count = NULL;
    923         xs_html *pending_follow_count = NULL;
    924 
    925         /* show the number of new notifications, if there are any */
    926         if (n_len) {
    927             xs *n_len_str = xs_fmt(" %d ", n_len);
    928             notify_count = xs_html_tag("sup",
    929                 xs_html_attr("style", "background-color: red; color: white;"),
    930                 xs_html_text(n_len_str));
    931         }
    932         else
    933             notify_count = xs_html_text("");
    934 
    935         if (p_len) {
    936             xs *s = xs_fmt(" %d ", p_len);
    937             pending_follow_count = xs_html_tag("sup",
    938                 xs_html_attr("style", "background-color: red; color: white;"),
    939                 xs_html_text(s));
    940         }
    941         else
    942             pending_follow_count = xs_html_text("");
    943 
    944         xs *admin_url = xs_fmt("%s/admin", user->actor);
    945         xs *notify_url = xs_fmt("%s/notifications", user->actor);
    946         xs *people_url = xs_fmt("%s/people", user->actor);
    947         xs *instance_url = xs_fmt("%s/instance", user->actor);
    948 
    949         xs_html_add(top_nav,
    950             xs_html_tag("a",
    951                 xs_html_attr("href", user->actor),
    952                 xs_html_attr("id", "public"),
    953                 xs_html_text(L("public"))),
    954             xs_html_text(" - "),
    955             xs_html_tag("a",
    956                 xs_html_attr("href", admin_url),
    957                 xs_html_attr("id", "private"),
    958                 xs_html_text(L("private"))),
    959             xs_html_text(" - "),
    960             xs_html_tag("a",
    961                 xs_html_attr("href", notify_url),
    962                 xs_html_attr("id", "notifications"),
    963                 xs_html_text(L("notifications"))),
    964             notify_count,
    965             xs_html_text(" - "),
    966             xs_html_tag("a",
    967                 xs_html_attr("href", people_url),
    968                 xs_html_attr("id", "people"),
    969                 xs_html_text(L("people"))),
    970             pending_follow_count,
    971             xs_html_text(" - "),
    972             xs_html_tag("a",
    973                 xs_html_attr("href", instance_url),
    974                 xs_html_attr("id", "instance"),
    975                 xs_html_text(L("instance"))),
    976             xs_html_text(" "),
    977             xs_html_tag("form",
    978                 xs_html_attr("style", "display: inline!important"),
    979                 xs_html_attr("class", "snac-search-box"),
    980                 xs_html_attr("action", admin_url),
    981                     xs_html_sctag("input",
    982                         xs_html_attr("type", "text"),
    983                         xs_html_attr("name", "q"),
    984                         xs_html_attr("title", L("Search posts by URL or content (regular expression), @user@host accounts, or #tag")),
    985                         xs_html_attr("placeholder", L("Content search")))));
    986     }
    987 
    988     xs_html_add(body,
    989         top_nav);
    990 
    991     /* user info */
    992     xs_html *top_user = xs_html_tag("div",
    993         xs_html_attr("class", "h-card snac-top-user"));
    994 
    995     if (read_only) {
    996         const char *header = xs_dict_get(user->config, "header");
    997         if (header && *header) {
    998             xs_html_add(top_user,
    999                 xs_html_tag("div",
   1000                     xs_html_attr("class", "snac-top-user-banner"),
   1001                     xs_html_attr("style", "clear: both"),
   1002                     xs_html_sctag("br", NULL),
   1003                     xs_html_sctag("img",
   1004                         xs_html_attr("src", header))));
   1005         }
   1006     }
   1007 
   1008     xs *handle = xs_fmt("@%s@%s",
   1009         xs_dict_get(user->config, "uid"),
   1010         xs_dict_get(srv_config, "host"));
   1011 
   1012     xs *display_name = format_text_with_emoji(NULL, xs_dict_get(user->config, "name"), 1, proxy);
   1013 
   1014     xs_html_add(top_user,
   1015         xs_html_tag("p",
   1016             xs_html_attr("class", "p-name snac-top-user-name"),
   1017             xs_html_raw(display_name)),
   1018         xs_html_tag("p",
   1019             xs_html_attr("class", "snac-top-user-id"),
   1020             xs_html_text(handle)));
   1021 
   1022     /** instance announcement **/
   1023 
   1024     if (!read_only) {
   1025         double la = 0.0;
   1026         xs *user_la = xs_dup(xs_dict_get(user->config, "last_announcement"));
   1027         if (user_la != NULL)
   1028             la = xs_number_get(user_la);
   1029 
   1030         const t_announcement *an = announcement(la);
   1031         if (an != NULL && (an->text != NULL)) {
   1032             xs *s = xs_fmt("?da=%.0f", an->timestamp);
   1033 
   1034             xs_html_add(top_user,  xs_html_tag("div",
   1035                 xs_html_attr("class", "snac-announcement"),
   1036                     xs_html_text(an->text),
   1037                     xs_html_text(" "),
   1038                     xs_html_tag("a",
   1039                             xs_html_attr("href", s),
   1040                             xs_html_text("Dismiss"))));
   1041         }
   1042     }
   1043 
   1044     if (read_only) {
   1045         xs *bio = format_text_with_emoji(user, xs_dict_get(user->config, "bio"), 2, proxy);
   1046 
   1047         xs_html *top_user_bio = xs_html_tag("div",
   1048             xs_html_attr("class", "p-note snac-top-user-bio"),
   1049             xs_html_raw(bio)); /* already sanitized */
   1050 
   1051         xs_html_add(top_user,
   1052             top_user_bio);
   1053 
   1054         xs *metadata = NULL;
   1055         const xs_dict *md = xs_dict_get(user->config, "metadata");
   1056 
   1057         if (xs_type(md) == XSTYPE_DICT)
   1058             metadata = xs_dup(md);
   1059         else
   1060         if (xs_type(md) == XSTYPE_STRING) {
   1061             /* convert to dict for easier iteration */
   1062             metadata = xs_dict_new();
   1063             xs *l = xs_split(md, "\n");
   1064             const char *ll;
   1065 
   1066             xs_list_foreach(l, ll) {
   1067                 xs *kv = xs_split_n(ll, "=", 1);
   1068                 const char *k = xs_list_get(kv, 0);
   1069                 const char *v = xs_list_get(kv, 1);
   1070 
   1071                 if (k && v) {
   1072                     xs *kk = xs_strip_i(xs_dup(k));
   1073                     xs *vv = xs_strip_i(xs_dup(v));
   1074                     metadata = xs_dict_set(metadata, kk, vv);
   1075                 }
   1076             }
   1077         }
   1078 
   1079         if (xs_type(metadata) == XSTYPE_DICT) {
   1080             const xs_str *k;
   1081             const xs_str *v;
   1082 
   1083             xs_dict *val_links = user->links;
   1084             if (xs_is_null(val_links))
   1085                 val_links = xs_stock(XSTYPE_DICT);
   1086 
   1087             xs_html *snac_metadata = xs_html_tag("div",
   1088                 xs_html_attr("class", "snac-metadata"));
   1089 
   1090             int c = 0;
   1091             while (xs_dict_next(metadata, &k, &v, &c)) {
   1092                 xs_html *value;
   1093 
   1094                 if (xs_startswith(v, "https:/") || xs_startswith(v, "http:/") || *v == '@') {
   1095                     /* is this link validated? */
   1096                     xs *verified_link = NULL;
   1097                     const xs_number *val_time = xs_dict_get(val_links, v);
   1098                     const char *url = NULL;
   1099 
   1100                     if (xs_is_string(val_time)) {
   1101                         /* resolve again, as it may be an account handle */
   1102                         url = val_time;
   1103                         val_time = xs_dict_get(val_links, val_time);
   1104                     }
   1105 
   1106                     if (xs_type(val_time) == XSTYPE_NUMBER) {
   1107                         time_t t = xs_number_get(val_time);
   1108 
   1109                         if (t > 0) {
   1110                             xs *s1 = xs_str_utctime(t, ISO_DATE_SPEC);
   1111                             verified_link = xs_fmt("%s (%s)", L("verified link"), s1);
   1112                         }
   1113                     }
   1114 
   1115                     if (!xs_is_null(verified_link)) {
   1116                         value = xs_html_tag("span",
   1117                             xs_html_attr("title", verified_link),
   1118                             xs_html_raw("&#10004; "),
   1119                             xs_html_tag("a",
   1120                                 xs_html_attr("rel", "me"),
   1121                                 xs_html_attr("target", "_blank"),
   1122                                 xs_html_attr("href", url ? url : v),
   1123                                 xs_html_text(v)));
   1124                     }
   1125                     else {
   1126                         value = xs_html_tag("a",
   1127                             xs_html_attr("rel", "me"),
   1128                             xs_html_attr("href", url ? url : v),
   1129                             xs_html_text(v));
   1130                     }
   1131                 }
   1132                 else
   1133                 if (xs_startswith(v, "gemini:/") || xs_startswith(v, "xmpp:")) {
   1134                     value = xs_html_tag("a",
   1135                         xs_html_attr("rel", "me"),
   1136                         xs_html_attr("href", v),
   1137                         xs_html_text(v));
   1138                 }
   1139                 else
   1140                     value = xs_html_text(v);
   1141 
   1142                 xs_html_add(snac_metadata,
   1143                     xs_html_tag("span",
   1144                         xs_html_attr("class", "snac-property-name"),
   1145                         xs_html_text(k)),
   1146                     xs_html_text(":"),
   1147                     xs_html_raw("&nbsp;"),
   1148                     xs_html_tag("span",
   1149                         xs_html_attr("class", "snac-property-value"),
   1150                         value),
   1151                     xs_html_sctag("br", NULL));
   1152             }
   1153 
   1154             xs_html_add(top_user,
   1155                 snac_metadata);
   1156         }
   1157 
   1158         const char *latitude = xs_dict_get_def(user->config, "latitude", "");
   1159         const char *longitude = xs_dict_get_def(user->config, "longitude", "");
   1160 
   1161         if (*latitude && *longitude) {
   1162             xs *label = xs_fmt("%s,%s", latitude, longitude);
   1163             xs *url   = xs_fmt("https://openstreetmap.org/search?query=%s,%s",
   1164                         latitude, longitude);
   1165 
   1166             xs_html_add(top_user,
   1167                 xs_html_tag("p",
   1168                     xs_html_text(L("Location: ")),
   1169                     xs_html_tag("a",
   1170                         xs_html_attr("href", url),
   1171                         xs_html_attr("target", "_blank"),
   1172                         xs_html_text(label))));
   1173         }
   1174 
   1175         if (xs_is_true(xs_dict_get(user->config, "show_contact_metrics"))) {
   1176             xs *s1 = xs_fmt(L("%d following, %d followers"), following_list_len(user), follower_list_len(user));
   1177 
   1178             xs_html_add(top_user,
   1179                 xs_html_tag("p",
   1180                     xs_html_text(s1)));
   1181         }
   1182     }
   1183 
   1184     xs_html_add(body,
   1185         top_user);
   1186 
   1187     return body;
   1188 }
   1189 
   1190 
   1191 xs_html *html_top_controls(snac *user)
   1192 /* generates the top controls */
   1193 {
   1194     xs *ops_action = xs_fmt("%s/admin/action", user->actor);
   1195 
   1196     xs_html *top_controls = xs_html_tag("div",
   1197         xs_html_attr("class", "snac-top-controls"),
   1198 
   1199         /** new post **/
   1200         html_note(user, L("New Post..."),
   1201             "new_post_div", "new_post_form",
   1202             L("What's on your mind?"), "",
   1203             NULL, NULL,
   1204             xs_stock(XSTYPE_FALSE), "",
   1205             xs_stock(XSTYPE_FALSE), NULL,
   1206             NULL, 1, NULL, NULL, 0, NULL),
   1207 
   1208         /** operations **/
   1209         xs_html_tag("details",
   1210             xs_html_tag("summary",
   1211                 xs_html_text(L("Operations..."))),
   1212             xs_html_tag("p", NULL),
   1213             xs_html_tag("form",
   1214                 xs_html_attr("autocomplete", "off"),
   1215                 xs_html_attr("method",       "post"),
   1216                 xs_html_attr("action",       ops_action),
   1217                 xs_html_sctag("input",
   1218                     xs_html_attr("type",     "text"),
   1219                     xs_html_attr("name",     "actor"),
   1220                     xs_html_attr("required", "required"),
   1221                     xs_html_attr("placeholder", "bob@example.com")),
   1222                 xs_html_text(" "),
   1223                 xs_html_sctag("input",
   1224                     xs_html_attr("type",    "submit"),
   1225                     xs_html_attr("name",    "action"),
   1226                     xs_html_attr("value",   L("Follow"))),
   1227             xs_html_text(" "),
   1228             xs_html_text(L("(by URL or user@host)"))),
   1229             xs_html_tag("p", NULL),
   1230             xs_html_tag("form",
   1231                 xs_html_attr("autocomplete", "off"),
   1232                 xs_html_attr("method",       "post"),
   1233                 xs_html_attr("action",       ops_action),
   1234                 xs_html_sctag("input",
   1235                     xs_html_attr("type",     "url"),
   1236                     xs_html_attr("name",     "id"),
   1237                     xs_html_attr("required", "required"),
   1238                     xs_html_attr("placeholder", "https:/" "/fedi.example.com/bob/...")),
   1239                 xs_html_text(" "),
   1240                 xs_html_sctag("input",
   1241                     xs_html_attr("type",    "submit"),
   1242                     xs_html_attr("name",    "action"),
   1243                     xs_html_attr("value",   L("Boost"))),
   1244                 xs_html_text(" "),
   1245                 xs_html_text(L("(by URL)"))),
   1246             xs_html_tag("p", NULL),
   1247             xs_html_tag("form",
   1248                 xs_html_attr("autocomplete", "off"),
   1249                 xs_html_attr("method",       "post"),
   1250                 xs_html_attr("action",       ops_action),
   1251                 xs_html_sctag("input",
   1252                     xs_html_attr("type",     "text"),
   1253                     xs_html_attr("name",     "id"),
   1254                     xs_html_attr("required", "required"),
   1255                     xs_html_attr("placeholder", "https:/" "/fedi.example.com/bob/...")),
   1256                 xs_html_text(" "),
   1257                 xs_html_sctag("input",
   1258                     xs_html_attr("type",    "submit"),
   1259                     xs_html_attr("name",    "action"),
   1260                     xs_html_attr("value",   L("Like"))),
   1261                 xs_html_text(" "),
   1262                 xs_html_text(L("(by URL)"))),
   1263             xs_html_tag("p", NULL)));
   1264 
   1265     /** user settings **/
   1266 
   1267     const char *email = "[disabled by admin]";
   1268 
   1269     if (xs_type(xs_dict_get(srv_config, "disable_email_notifications")) != XSTYPE_TRUE) {
   1270         email = xs_dict_get(user->config_o, "email");
   1271         if (xs_is_null(email)) {
   1272             email = xs_dict_get(user->config, "email");
   1273 
   1274             if (xs_is_null(email))
   1275                 email = "";
   1276         }
   1277     }
   1278 
   1279     const char *cw = xs_dict_get(user->config, "cw");
   1280     if (xs_is_null(cw))
   1281         cw = "";
   1282 
   1283     const char *telegram_bot = xs_dict_get(user->config, "telegram_bot");
   1284     if (xs_is_null(telegram_bot))
   1285         telegram_bot = "";
   1286 
   1287     const char *telegram_chat_id = xs_dict_get(user->config, "telegram_chat_id");
   1288     if (xs_is_null(telegram_chat_id))
   1289         telegram_chat_id = "";
   1290 
   1291     const char *ntfy_server = xs_dict_get(user->config, "ntfy_server");
   1292     if (xs_is_null(ntfy_server))
   1293         ntfy_server = "";
   1294 
   1295     const char *ntfy_token = xs_dict_get(user->config, "ntfy_token");
   1296     if (xs_is_null(ntfy_token))
   1297         ntfy_token = "";
   1298 
   1299     const char *purge_days = xs_dict_get(user->config, "purge_days");
   1300     if (!xs_is_null(purge_days) && xs_type(purge_days) == XSTYPE_NUMBER)
   1301         purge_days = (char *)xs_number_str(purge_days);
   1302     else
   1303         purge_days = "0";
   1304 
   1305     const xs_val *d_dm_f_u  = xs_dict_get(user->config, "drop_dm_from_unknown");
   1306     const xs_val *bot       = xs_dict_get(user->config, "bot");
   1307     const xs_val *a_private = xs_dict_get(user->config, "private");
   1308     const xs_val *auto_boost = xs_dict_get(user->config, "auto_boost");
   1309     const xs_val *coll_thrds = xs_dict_get(user->config, "collapse_threads");
   1310     const xs_val *pending    = xs_dict_get(user->config, "approve_followers");
   1311     const xs_val *show_foll  = xs_dict_get(user->config, "show_contact_metrics");
   1312     const char *latitude     = xs_dict_get_def(user->config, "latitude", "");
   1313     const char *longitude    = xs_dict_get_def(user->config, "longitude", "");
   1314     const char *webhook      = xs_dict_get_def(user->config, "notify_webhook", "");
   1315 
   1316     xs *metadata = NULL;
   1317     const xs_val *md = xs_dict_get(user->config, "metadata");
   1318 
   1319     if (xs_type(md) == XSTYPE_DICT) {
   1320         xs_str_bld b = { 0 };
   1321         const xs_str *k;
   1322         const xs_str *v;
   1323         const char *s = "";
   1324 
   1325         xs_dict_foreach(md, k, v) {
   1326             xs_str_bld_cat_fmt(&b, "%s%s=%s", s, k, v);
   1327             s = "\n";
   1328         }
   1329         md = metadata = b.data;
   1330     }
   1331     else
   1332     if (xs_type(md) != XSTYPE_STRING)
   1333         md = NULL;
   1334 
   1335     /* ui language */
   1336     xs_html *lang_select = xs_html_tag("select",
   1337         xs_html_attr("name", "web_ui_lang"));
   1338 
   1339     const char *u_lang = xs_dict_get_def(user->config, "lang", "en");
   1340     const char *lang;
   1341     const xs_dict *langs;
   1342 
   1343     xs_dict_foreach(srv_langs, lang, langs) {
   1344         if (strcmp(u_lang, lang) == 0)
   1345             xs_html_add(lang_select,
   1346                 xs_html_tag("option",
   1347                     xs_html_text(lang),
   1348                     xs_html_attr("value", lang),
   1349                     xs_html_attr("selected", "selected")));
   1350         else
   1351             xs_html_add(lang_select,
   1352                 xs_html_tag("option",
   1353                     xs_html_text(lang),
   1354                     xs_html_attr("value", lang)));
   1355     }
   1356 
   1357     /* timezone */
   1358     xs_html *tz_select = xs_html_tag("select",
   1359         xs_html_attr("name", "tz"));
   1360 
   1361     xs *tzs = xs_tz_list();
   1362     const char *tz;
   1363 
   1364     xs_list_foreach(tzs, tz) {
   1365         if (strcmp(tz, user->tz) == 0)
   1366             xs_html_add(tz_select,
   1367                 xs_html_tag("option",
   1368                 xs_html_text(tz),
   1369                 xs_html_attr("value", tz),
   1370                 xs_html_attr("selected", "selected")));
   1371         else
   1372             xs_html_add(tz_select,
   1373                 xs_html_tag("option",
   1374                 xs_html_text(tz),
   1375                 xs_html_attr("value", tz)));
   1376     }
   1377 
   1378     xs *user_setup_action = xs_fmt("%s/admin/user-setup", user->actor);
   1379 
   1380     xs_html_add(top_controls,
   1381         xs_html_tag("details",
   1382         xs_html_tag("summary",
   1383             xs_html_text(L("User Settings..."))),
   1384         xs_html_tag("div",
   1385             xs_html_attr("class", "snac-user-setup"),
   1386             xs_html_tag("form",
   1387                 xs_html_attr("autocomplete", "off"),
   1388                 xs_html_attr("method",       "post"),
   1389                 xs_html_attr("action",       user_setup_action),
   1390                 xs_html_attr("enctype",      "multipart/form-data"),
   1391                 xs_html_tag("p",
   1392                     xs_html_text(L("Display name:")),
   1393                     xs_html_sctag("br", NULL),
   1394                     xs_html_sctag("input",
   1395                         xs_html_attr("type", "text"),
   1396                         xs_html_attr("name", "name"),
   1397                         xs_html_attr("value", xs_dict_get(user->config, "name")),
   1398                         xs_html_attr("placeholder", L("Your name")))),
   1399                 xs_html_tag("p",
   1400                     xs_html_text(L("Avatar: ")),
   1401                     xs_html_sctag("input",
   1402                         xs_html_attr("type", "file"),
   1403                         xs_html_attr("name", "avatar_file"))),
   1404                 xs_html_tag("p",
   1405                     xs_html_sctag("input",
   1406                         xs_html_attr("type", "checkbox"),
   1407                         xs_html_attr("name", "avatar_delete")),
   1408                         xs_html_text(L("Delete current avatar"))),
   1409                 xs_html_tag("p",
   1410                     xs_html_text(L("Header image (banner): ")),
   1411                     xs_html_sctag("input",
   1412                         xs_html_attr("type", "file"),
   1413                         xs_html_attr("name", "header_file"))),
   1414                 xs_html_tag("p",
   1415                     xs_html_sctag("input",
   1416                         xs_html_attr("type", "checkbox"),
   1417                         xs_html_attr("name", "header_delete")),
   1418                         xs_html_text(L("Delete current header image"))),
   1419                 xs_html_tag("p",
   1420                     xs_html_text(L("Bio:")),
   1421                     xs_html_sctag("br", NULL),
   1422                     xs_html_tag("textarea",
   1423                         xs_html_attr("name", "bio"),
   1424                         xs_html_attr("cols", "40"),
   1425                         xs_html_attr("rows", "4"),
   1426                         xs_html_attr("placeholder", L("Write about yourself here...")),
   1427                         xs_html_text(xs_dict_get(user->config, "bio")))),
   1428                 xs_html_sctag("input",
   1429                     xs_html_attr("type", "checkbox"),
   1430                     xs_html_attr("name", "cw"),
   1431                     xs_html_attr("id",   "cw"),
   1432                     xs_html_attr(strcmp(cw, "open") == 0 ? "checked" : "", NULL)),
   1433                 xs_html_tag("label",
   1434                     xs_html_attr("for", "cw"),
   1435                     xs_html_text(L("Always show sensitive content"))),
   1436                 xs_html_tag("p",
   1437                     xs_html_text(L("Email address for notifications:")),
   1438                     xs_html_sctag("br", NULL),
   1439                     xs_html_sctag("input",
   1440                         xs_html_attr("type", "text"),
   1441                         xs_html_attr("name", "email"),
   1442                         xs_html_attr("value", email),
   1443                         xs_html_attr("placeholder", "bob@example.com"))),
   1444                 xs_html_tag("p",
   1445                     xs_html_text(L("Telegram notifications (bot key and chat id):")),
   1446                     xs_html_sctag("br", NULL),
   1447                     xs_html_sctag("input",
   1448                         xs_html_attr("type", "text"),
   1449                         xs_html_attr("name", "telegram_bot"),
   1450                         xs_html_attr("value", telegram_bot),
   1451                         xs_html_attr("placeholder", L("Bot API key"))),
   1452                     xs_html_text(" "),
   1453                     xs_html_sctag("input",
   1454                         xs_html_attr("type", "text"),
   1455                         xs_html_attr("name", "telegram_chat_id"),
   1456                         xs_html_attr("value", telegram_chat_id),
   1457                         xs_html_attr("placeholder", L("Chat id")))),
   1458                 xs_html_tag("p",
   1459                     xs_html_text(L("ntfy notifications (ntfy server and token):")),
   1460                     xs_html_sctag("br", NULL),
   1461                     xs_html_sctag("input",
   1462                         xs_html_attr("type", "text"),
   1463                         xs_html_attr("name", "ntfy_server"),
   1464                         xs_html_attr("value", ntfy_server),
   1465                         xs_html_attr("placeholder", L("ntfy server - full URL (example: https://ntfy.sh/YourTopic)"))),
   1466                     xs_html_text(" "),
   1467                     xs_html_sctag("input",
   1468                         xs_html_attr("type", "text"),
   1469                         xs_html_attr("name", "ntfy_token"),
   1470                         xs_html_attr("value", ntfy_token),
   1471                         xs_html_attr("placeholder", L("ntfy token - if needed")))),
   1472                 xs_html_tag("p",
   1473                     xs_html_text(L("Notify webhook:")),
   1474                     xs_html_sctag("br", NULL),
   1475                     xs_html_sctag("input",
   1476                         xs_html_attr("type", "url"),
   1477                         xs_html_attr("name", "notify_webhook"),
   1478                         xs_html_attr("value", webhook),
   1479                         xs_html_attr("placeholder", L("http://example.com/webhook")))),
   1480                 xs_html_tag("p",
   1481                     xs_html_text(L("Maximum days to keep posts (0: server settings):")),
   1482                     xs_html_sctag("br", NULL),
   1483                     xs_html_sctag("input",
   1484                         xs_html_attr("type", "number"),
   1485                         xs_html_attr("name", "purge_days"),
   1486                         xs_html_attr("value", purge_days))),
   1487                 xs_html_tag("p",
   1488                     xs_html_sctag("input",
   1489                         xs_html_attr("type", "checkbox"),
   1490                         xs_html_attr("name", "drop_dm_from_unknown"),
   1491                         xs_html_attr("id", "drop_dm_from_unknown"),
   1492                         xs_html_attr(xs_type(d_dm_f_u) == XSTYPE_TRUE ? "checked" : "", NULL)),
   1493                     xs_html_tag("label",
   1494                         xs_html_attr("for", "drop_dm_from_unknown"),
   1495                         xs_html_text(L("Drop direct messages from people you don't follow")))),
   1496                 xs_html_tag("p",
   1497                     xs_html_sctag("input",
   1498                         xs_html_attr("type", "checkbox"),
   1499                         xs_html_attr("name", "bot"),
   1500                         xs_html_attr("id", "bot"),
   1501                         xs_html_attr(xs_type(bot) == XSTYPE_TRUE ? "checked" : "", NULL)),
   1502                     xs_html_tag("label",
   1503                         xs_html_attr("for", "bot"),
   1504                         xs_html_text(L("This account is a bot")))),
   1505                 xs_html_tag("p",
   1506                     xs_html_sctag("input",
   1507                         xs_html_attr("type", "checkbox"),
   1508                         xs_html_attr("name", "auto_boost"),
   1509                         xs_html_attr("id", "auto_boost"),
   1510                         xs_html_attr(xs_is_true(auto_boost) ? "checked" : "", NULL)),
   1511                     xs_html_tag("label",
   1512                         xs_html_attr("for", "auto_boost"),
   1513                         xs_html_text(L("Auto-boost all mentions to this account")))),
   1514                 xs_html_tag("p",
   1515                     xs_html_sctag("input",
   1516                         xs_html_attr("type", "checkbox"),
   1517                         xs_html_attr("name", "private"),
   1518                         xs_html_attr("id", "private"),
   1519                         xs_html_attr(xs_type(a_private) == XSTYPE_TRUE ? "checked" : "", NULL)),
   1520                     xs_html_tag("label",
   1521                         xs_html_attr("for", "private"),
   1522                         xs_html_text(L("This account is private "
   1523                             "(posts are not shown through the web)")))),
   1524                 xs_html_tag("p",
   1525                     xs_html_sctag("input",
   1526                         xs_html_attr("type", "checkbox"),
   1527                         xs_html_attr("name", "collapse_threads"),
   1528                         xs_html_attr("id",   "collapse_threads"),
   1529                         xs_html_attr(xs_is_true(coll_thrds) ? "checked" : "", NULL)),
   1530                     xs_html_tag("label",
   1531                         xs_html_attr("for", "collapse_threads"),
   1532                         xs_html_text(L("Collapse top threads by default")))),
   1533                 xs_html_tag("p",
   1534                     xs_html_sctag("input",
   1535                         xs_html_attr("type", "checkbox"),
   1536                         xs_html_attr("name", "approve_followers"),
   1537                         xs_html_attr("id",   "approve_followers"),
   1538                         xs_html_attr(xs_is_true(pending) ? "checked" : "", NULL)),
   1539                     xs_html_tag("label",
   1540                         xs_html_attr("for", "approve_followers"),
   1541                         xs_html_text(L("Follow requests must be approved")))),
   1542                 xs_html_tag("p",
   1543                     xs_html_sctag("input",
   1544                         xs_html_attr("type", "checkbox"),
   1545                         xs_html_attr("name", "show_contact_metrics"),
   1546                         xs_html_attr("id",   "show_contact_metrics"),
   1547                         xs_html_attr(xs_is_true(show_foll) ? "checked" : "", NULL)),
   1548                     xs_html_tag("label",
   1549                         xs_html_attr("for", "show_contact_metrics"),
   1550                         xs_html_text(L("Publish follower and following metrics")))),
   1551                 xs_html_tag("p",
   1552                     xs_html_text(L("Current location:")),
   1553                     xs_html_sctag("br", NULL),
   1554                     xs_html_sctag("input",
   1555                         xs_html_attr("type", "text"),
   1556                         xs_html_attr("name", "latitude"),
   1557                         xs_html_attr("value", latitude),
   1558                         xs_html_attr("placeholder", "latitude")),
   1559                     xs_html_text(" "),
   1560                     xs_html_sctag("input",
   1561                         xs_html_attr("type", "text"),
   1562                         xs_html_attr("name", "longitude"),
   1563                         xs_html_attr("value", longitude),
   1564                         xs_html_attr("placeholder", "longitude"))),
   1565                 xs_html_tag("p",
   1566                     xs_html_text(L("Profile metadata (key=value pairs in each line):")),
   1567                     xs_html_sctag("br", NULL),
   1568                     xs_html_tag("textarea",
   1569                         xs_html_attr("name", "metadata"),
   1570                         xs_html_attr("cols", "40"),
   1571                         xs_html_attr("rows", "4"),
   1572                         xs_html_attr("placeholder", "Blog=https:/"
   1573                             "/example.com/my-blog\nGPG Key=1FA54\n..."),
   1574                     xs_html_text(md ? md : ""))),
   1575 
   1576                 xs_html_tag("p",
   1577                     xs_html_text(L("Web interface language:")),
   1578                     xs_html_sctag("br", NULL),
   1579                     lang_select),
   1580 
   1581                 xs_html_tag("p",
   1582                     xs_html_text(L("Time zone:")),
   1583                     xs_html_sctag("br", NULL),
   1584                     tz_select),
   1585 
   1586                 xs_html_tag("p",
   1587                     xs_html_text(L("New password:")),
   1588                     xs_html_sctag("br", NULL),
   1589                     xs_html_sctag("input",
   1590                         xs_html_attr("type", "password"),
   1591                         xs_html_attr("name", "passwd1"),
   1592                         xs_html_attr("value", ""))),
   1593                 xs_html_tag("p",
   1594                     xs_html_text(L("Repeat new password:")),
   1595                     xs_html_sctag("br", NULL),
   1596                     xs_html_sctag("input",
   1597                         xs_html_attr("type", "password"),
   1598                         xs_html_attr("name", "passwd2"),
   1599                         xs_html_attr("value", ""))),
   1600 
   1601                 xs_html_sctag("input",
   1602                     xs_html_attr("type", "submit"),
   1603                     xs_html_attr("class", "button"),
   1604                     xs_html_attr("value", L("Update user info"))),
   1605 
   1606                 xs_html_tag("p", NULL)))));
   1607 
   1608     xs *followed_hashtags_action = xs_fmt("%s/admin/followed-hashtags", user->actor);
   1609     xs *followed_hashtags = xs_join(xs_dict_get_def(user->config,
   1610                         "followed_hashtags", xs_stock(XSTYPE_LIST)), "\n");
   1611 
   1612     xs_html_add(top_controls,
   1613         xs_html_tag("details",
   1614         xs_html_tag("summary",
   1615             xs_html_text(L("Followed hashtags..."))),
   1616         xs_html_tag("p",
   1617             xs_html_text(L("One hashtag per line"))),
   1618         xs_html_tag("div",
   1619             xs_html_attr("class", "snac-followed-hashtags"),
   1620             xs_html_tag("form",
   1621                 xs_html_attr("autocomplete", "off"),
   1622                 xs_html_attr("method", "post"),
   1623                 xs_html_attr("action", followed_hashtags_action),
   1624                 xs_html_attr("enctype", "multipart/form-data"),
   1625 
   1626                 xs_html_tag("textarea",
   1627                     xs_html_attr("name", "followed_hashtags"),
   1628                     xs_html_attr("cols", "40"),
   1629                     xs_html_attr("rows", "4"),
   1630                     xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic\nhttps:/"
   1631                         "/mastodon.social/tags/dogs"),
   1632                     xs_html_text(followed_hashtags)),
   1633 
   1634                 xs_html_tag("br", NULL),
   1635 
   1636                 xs_html_sctag("input",
   1637                     xs_html_attr("type", "submit"),
   1638                     xs_html_attr("class", "button"),
   1639                     xs_html_attr("value", L("Update hashtags")))))));
   1640 
   1641     xs *blocked_hashtags_action = xs_fmt("%s/admin/blocked-hashtags", user->actor);
   1642     xs *blocked_hashtags = xs_join(xs_dict_get_def(user->config,
   1643                         "blocked_hashtags", xs_stock(XSTYPE_LIST)), "\n");
   1644 
   1645     xs_html_add(top_controls,
   1646         xs_html_tag("details",
   1647         xs_html_tag("summary",
   1648             xs_html_text(L("Blocked hashtags..."))),
   1649         xs_html_tag("p",
   1650             xs_html_text(L("One hashtag per line"))),
   1651         xs_html_tag("div",
   1652             xs_html_attr("class", "snac-blocked-hashtags"),
   1653             xs_html_tag("form",
   1654                 xs_html_attr("autocomplete", "off"),
   1655                 xs_html_attr("method", "post"),
   1656                 xs_html_attr("action", blocked_hashtags_action),
   1657                 xs_html_attr("enctype", "multipart/form-data"),
   1658 
   1659                 xs_html_tag("textarea",
   1660                     xs_html_attr("name", "blocked_hashtags"),
   1661                     xs_html_attr("cols", "40"),
   1662                     xs_html_attr("rows", "4"),
   1663                     xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic"),
   1664                     xs_html_text(blocked_hashtags)),
   1665 
   1666                 xs_html_tag("br", NULL),
   1667 
   1668                 xs_html_sctag("input",
   1669                     xs_html_attr("type", "submit"),
   1670                     xs_html_attr("class", "button"),
   1671                     xs_html_attr("value", L("Update hashtags")))))));
   1672 
   1673     return top_controls;
   1674 }
   1675 
   1676 
   1677 static xs_html *html_button(const char *clss, const char *label, const char *hint)
   1678 {
   1679     xs *c = xs_fmt("snac-btn-%s", clss);
   1680 
   1681     /* use an NULL tag to separate non-css-classed buttons from one another */
   1682     return xs_html_container(
   1683         xs_html_sctag("input",
   1684             xs_html_attr("type",    "submit"),
   1685             xs_html_attr("name",    "action"),
   1686             xs_html_attr("class",   c),
   1687             xs_html_attr("value",   label),
   1688             xs_html_attr("title",   hint)),
   1689         xs_html_text("\n"));
   1690 }
   1691 
   1692 
   1693 xs_str *build_mentions(snac *user, const xs_dict *msg)
   1694 /* returns a string with the mentions in msg */
   1695 {
   1696     xs_str *s = xs_str_new(NULL);
   1697     const char *list = xs_dict_get(msg, "tag");
   1698     const char *v;
   1699     int c = 0;
   1700 
   1701     while (xs_list_next(list, &v, &c)) {
   1702         const char *type = xs_dict_get(v, "type");
   1703         const char *href = xs_dict_get(v, "href");
   1704         const char *name = xs_dict_get(v, "name");
   1705 
   1706         if (type && strcmp(type, "Mention") == 0 &&
   1707             href && strcmp(href, user->actor) != 0 && name) {
   1708             xs *s1 = NULL;
   1709 
   1710             if (name[0] != '@') {
   1711                 s1 = xs_fmt("@%s", name);
   1712                 name = s1;
   1713             }
   1714 
   1715             xs *l = xs_split(name, "@");
   1716 
   1717             /* is it a name without a host? */
   1718             if (xs_list_len(l) < 3) {
   1719                 /* split the href and pick the host name LIKE AN ANIMAL */
   1720                 /* would be better to query the webfinger but *won't do that* here */
   1721                 xs *l2 = xs_split(href, "/");
   1722 
   1723                 if (xs_list_len(l2) >= 3) {
   1724                     xs *s1 = xs_fmt("%s@%s ", name, xs_list_get(l2, 2));
   1725 
   1726                     if (xs_str_in(s, s1) == -1)
   1727                         s = xs_str_cat(s, s1);
   1728                 }
   1729             }
   1730             else {
   1731                 if (xs_str_in(s, name) == -1) {
   1732                     s = xs_str_cat(s, name);
   1733                     s = xs_str_cat(s, " ");
   1734                 }
   1735             }
   1736         }
   1737     }
   1738 
   1739     if (*s) {
   1740         xs *s1 = s;
   1741         s = xs_fmt("\n\n\nCC: %s", s1);
   1742     }
   1743 
   1744     return s;
   1745 }
   1746 
   1747 
   1748 xs_html *html_entry_controls(snac *user, const char *actor,
   1749                             const xs_dict *msg, const char *md5)
   1750 {
   1751     const char *id    = xs_dict_get(msg, "id");
   1752     const char *group = xs_dict_get(msg, "audience");
   1753 
   1754     xs *likes   = object_likes(id);
   1755     xs *boosts  = object_announces(id);
   1756 
   1757     xs *action = xs_fmt("%s/admin/action", user->actor);
   1758     xs *redir  = xs_fmt("%s_entry", md5);
   1759 
   1760     xs_html *form;
   1761     xs_html *controls = xs_html_tag("div",
   1762         xs_html_attr("class", "snac-controls"),
   1763         form = xs_html_tag("form",
   1764             xs_html_attr("autocomplete", "off"),
   1765             xs_html_attr("method",       "post"),
   1766             xs_html_attr("action",       action),
   1767             xs_html_sctag("input",
   1768                 xs_html_attr("type",     "hidden"),
   1769                 xs_html_attr("name",     "id"),
   1770                 xs_html_attr("value",    id)),
   1771             xs_html_sctag("input",
   1772                 xs_html_attr("type",     "hidden"),
   1773                 xs_html_attr("name",     "actor"),
   1774                 xs_html_attr("value",    actor)),
   1775             xs_html_sctag("input",
   1776                 xs_html_attr("type",     "hidden"),
   1777                 xs_html_attr("name",     "group"),
   1778                 xs_html_attr("value",    xs_is_null(group) ? "" : group)),
   1779             xs_html_sctag("input",
   1780                 xs_html_attr("type",     "hidden"),
   1781                 xs_html_attr("name",     "redir"),
   1782                 xs_html_attr("value",    redir))));
   1783 
   1784     if (!xs_startswith(id, user->actor)) {
   1785         if (xs_list_in(likes, user->md5) == -1) {
   1786             /* not already liked; add button */
   1787             xs_html_add(form,
   1788                 html_button("like", L("Like"), L("Say you like this post")));
   1789         }
   1790         else {
   1791             /* not like it anymore */
   1792             xs_html_add(form,
   1793                 html_button("unlike", L("Unlike"), L("Nah don't like it that much")));
   1794         }
   1795     }
   1796     else {
   1797         if (is_pinned(user, id))
   1798             xs_html_add(form,
   1799                 html_button("unpin", L("Unpin"), L("Unpin this post from your timeline")));
   1800         else
   1801             xs_html_add(form,
   1802                 html_button("pin", L("Pin"), L("Pin this post to the top of your timeline")));
   1803     }
   1804 
   1805     if (is_msg_public(msg)) {
   1806         if (xs_list_in(boosts, user->md5) == -1) {
   1807             /* not already boosted; add button */
   1808             xs_html_add(form,
   1809                 html_button("boost", L("Boost"), L("Announce this post to your followers")));
   1810         }
   1811         else {
   1812             /* already boosted; add button to regret */
   1813             xs_html_add(form,
   1814                 html_button("unboost", L("Unboost"), L("I regret I boosted this")));
   1815         }
   1816     }
   1817 
   1818     if (is_bookmarked(user, id))
   1819             xs_html_add(form,
   1820                 html_button("unbookmark", L("Unbookmark"), L("Delete this post from your bookmarks")));
   1821         else
   1822             xs_html_add(form,
   1823                 html_button("bookmark", L("Bookmark"), L("Add this post to your bookmarks")));
   1824 
   1825     if (strcmp(actor, user->actor) != 0) {
   1826         /* controls for other actors than this one */
   1827         if (following_check(user, actor)) {
   1828             xs_html_add(form,
   1829                 html_button("unfollow", L("Unfollow"), L("Stop following this user's activity")));
   1830         }
   1831         else {
   1832             xs_html_add(form,
   1833                 html_button("follow", L("Follow"), L("Start following this user's activity")));
   1834         }
   1835 
   1836         if (!xs_is_null(group)) {
   1837             if (following_check(user, group)) {
   1838                 xs_html_add(form,
   1839                     html_button("unfollow", L("Unfollow Group"),
   1840                         L("Stop following this group or channel")));
   1841             }
   1842             else {
   1843                 xs_html_add(form,
   1844                     html_button("follow", L("Follow Group"),
   1845                         L("Start following this group or channel")));
   1846             }
   1847         }
   1848 
   1849         xs_html_add(form,
   1850             html_button("mute", L("MUTE"),
   1851                 L("Block any activity from this user forever")));
   1852     }
   1853 
   1854     if (!xs_is_true(xs_dict_get(srv_config, "hide_delete_post_button")))
   1855         xs_html_add(form,
   1856             html_button("delete", L("Delete"), L("Delete this post")));
   1857 
   1858     xs_html_add(form,
   1859         html_button("hide",   L("Hide"), L("Hide this post and its children")));
   1860 
   1861     const char *prev_src = xs_dict_get(msg, "sourceContent");
   1862 
   1863     if (!xs_is_null(prev_src) && strcmp(actor, user->actor) == 0) { /** edit **/
   1864         /* post can be edited */
   1865         xs *div_id  = xs_fmt("%s_edit", md5);
   1866         xs *form_id = xs_fmt("%s_edit_form", md5);
   1867         xs *redir   = xs_fmt("%s_entry", md5);
   1868 
   1869         xs *att_files = xs_list_new();
   1870         xs *att_alt_texts = xs_list_new();
   1871 
   1872         const xs_list *att_list = xs_dict_get(msg, "attachment");
   1873 
   1874         if (xs_is_list(att_list)) {
   1875             const xs_dict *d;
   1876 
   1877             xs_list_foreach(att_list, d) {
   1878                 const char *att_file = xs_dict_get(d, "url");
   1879                 const char *att_alt_text = xs_dict_get(d, "name");
   1880 
   1881                 if (xs_is_string(att_file) && xs_is_string(att_alt_text)) {
   1882                     att_files = xs_list_append(att_files, att_file);
   1883                     att_alt_texts = xs_list_append(att_alt_texts, att_alt_text);
   1884                 }
   1885             }
   1886         }
   1887 
   1888         xs_html_add(controls, xs_html_tag("div",
   1889             xs_html_tag("p", NULL),
   1890             html_note(user, L("Edit..."),
   1891                 div_id, form_id,
   1892                 "", prev_src,
   1893                 id, NULL,
   1894                 xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
   1895                 xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
   1896                 NULL, 0, att_files, att_alt_texts, is_draft(user, id),
   1897                 xs_dict_get(msg, "published"))),
   1898             xs_html_tag("p", NULL));
   1899     }
   1900 
   1901     { /** reply **/
   1902         /* the post textarea */
   1903         xs *ct      = build_mentions(user, msg);
   1904         xs *div_id  = xs_fmt("%s_reply", md5);
   1905         xs *form_id = xs_fmt("%s_reply_form", md5);
   1906         xs *redir   = xs_fmt("%s_entry", md5);
   1907 
   1908         xs_html_add(controls, xs_html_tag("div",
   1909             xs_html_tag("p", NULL),
   1910             html_note(user, L("Reply..."),
   1911                 div_id, form_id,
   1912                 "", ct,
   1913                 NULL, NULL,
   1914                 xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
   1915                 xs_stock(is_msg_public(msg) ? XSTYPE_FALSE : XSTYPE_TRUE), redir,
   1916                 id, 0, NULL, NULL, 0, NULL)),
   1917             xs_html_tag("p", NULL));
   1918     }
   1919 
   1920     return controls;
   1921 }
   1922 
   1923 
   1924 xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
   1925                    int level, const char *md5, int hide_children)
   1926 {
   1927     const char *id    = xs_dict_get(msg, "id");
   1928     const char *type  = xs_dict_get(msg, "type");
   1929     const char *actor;
   1930     const char *v;
   1931     int has_title = 0;
   1932     int collapse_threads = 0;
   1933     const char *proxy = NULL;
   1934 
   1935     if (user && !read_only && xs_is_true(xs_dict_get(srv_config, "proxy_media")))
   1936         proxy = user->actor;
   1937 
   1938     /* do not show non-public messages in the public timeline */
   1939     if ((read_only || !user) && !is_msg_public(msg))
   1940         return NULL;
   1941 
   1942     if (id && is_instance_blocked(id))
   1943         return NULL;
   1944 
   1945     if (user && level == 0 && xs_is_true(xs_dict_get(user->config, "collapse_threads")))
   1946         collapse_threads = 1;
   1947 
   1948     /* hidden? do nothing more for this conversation */
   1949     if (user && is_hidden(user, id)) {
   1950         xs *s1 = xs_fmt("%s_entry", md5);
   1951 
   1952         /* return just an dummy anchor, to keep position after hitting 'Hide' */
   1953         return xs_html_tag("div",
   1954             xs_html_tag("a",
   1955                 xs_html_attr("name", s1)));
   1956     }
   1957 
   1958     /* avoid too deep nesting, as it may be a loop */
   1959     if (level >= MAX_CONVERSATION_LEVELS)
   1960         return xs_html_tag("mark",
   1961             xs_html_text(L("Truncated (too deep)")));
   1962 
   1963     const char *lang = NULL;
   1964     const xs_dict *cmap = xs_dict_get(msg, "contentMap");
   1965     if (xs_is_dict(cmap)) {
   1966         const char *dummy;
   1967         int c = 0;
   1968 
   1969         xs_dict_next(cmap, &lang, &dummy, &c);
   1970     }
   1971 
   1972     if (strcmp(type, "Follow") == 0) {
   1973         return xs_html_tag("div",
   1974             xs_html_attr("class", "snac-post"),
   1975             xs_html_tag("div",
   1976                 xs_html_attr("class", "snac-post-header"),
   1977                 xs_html_tag("div",
   1978                     xs_html_attr("class", "snac-origin"),
   1979                     xs_html_text(L("follows you"))),
   1980                 html_msg_icon(read_only ? NULL : user, xs_dict_get(msg, "actor"), msg, proxy, NULL, lang)));
   1981     }
   1982     else
   1983     if (!xs_match(type, POSTLIKE_OBJECT_TYPE)) {
   1984         /* skip oddities */
   1985         snac_debug(user, 1, xs_fmt("html_entry: ignoring object type '%s' %s", type, id));
   1986         return NULL;
   1987     }
   1988 
   1989     /* ignore notes with "name", as they are votes to Questions */
   1990     if (strcmp(type, "Note") == 0 && !xs_is_null(xs_dict_get(msg, "name")))
   1991         return NULL;
   1992 
   1993     /* get the attributedTo */
   1994     if ((actor = get_atto(msg)) == NULL)
   1995         return NULL;
   1996 
   1997     /* ignore muted morons immediately */
   1998     if (user && is_muted(user, actor)) {
   1999         xs *s1 = xs_fmt("%s_entry", md5);
   2000 
   2001         /* return just an dummy anchor, to keep position after hitting 'MUTE' */
   2002         return xs_html_tag("div",
   2003             xs_html_tag("a",
   2004                 xs_html_attr("name", s1)));
   2005     }
   2006 
   2007     if ((user == NULL || strcmp(actor, user->actor) != 0)
   2008         && !valid_status(actor_get(actor, NULL)))
   2009         return NULL;
   2010 
   2011     /** html_entry top tag **/
   2012     xs_html *entry_top = xs_html_tag("div", NULL);
   2013 
   2014     {
   2015         xs *s1 = xs_fmt("%s_entry", md5);
   2016         xs_html_add(entry_top,
   2017             xs_html_tag("a",
   2018                 xs_html_attr("name", s1)));
   2019     }
   2020 
   2021     xs_html *entry = xs_html_tag("div",
   2022         xs_html_attr("class", level == 0 ? "snac-post" : "snac-child"));
   2023 
   2024     xs_html_add(entry_top,
   2025         entry);
   2026 
   2027     /** post header **/
   2028 
   2029     xs_html *score;
   2030     xs_html *post_header = xs_html_tag("div",
   2031         xs_html_attr("class", "snac-post-header"),
   2032         score = xs_html_tag("div",
   2033             xs_html_attr("class", "snac-score")));
   2034 
   2035     xs_html_add(entry,
   2036         post_header);
   2037 
   2038     if (user && is_pinned(user, id)) {
   2039         /* add a pin emoji */
   2040         xs_html_add(score,
   2041             xs_html_tag("span",
   2042                 xs_html_attr("title", L("Pinned")),
   2043                 xs_html_raw(" &#128204; ")));
   2044     }
   2045 
   2046     if (user && !read_only && is_bookmarked(user, id)) {
   2047         /* add a bookmark emoji */
   2048         xs_html_add(score,
   2049             xs_html_tag("span",
   2050                 xs_html_attr("title", L("Bookmarked")),
   2051                 xs_html_raw(" &#128278; ")));
   2052     }
   2053 
   2054     if (strcmp(type, "Question") == 0) {
   2055         /* add the ballot box emoji */
   2056         xs_html_add(score,
   2057             xs_html_tag("span",
   2058                 xs_html_attr("title", L("Poll")),
   2059                 xs_html_raw(" &#128499; ")));
   2060 
   2061         if (user && was_question_voted(user, id)) {
   2062             /* add a check to show this poll was voted */
   2063             xs_html_add(score,
   2064                 xs_html_tag("span",
   2065                     xs_html_attr("title", L("Voted")),
   2066                     xs_html_raw(" &#10003; ")));
   2067         }
   2068     }
   2069 
   2070     if (strcmp(type, "Event") == 0) {
   2071         /* add the calendar emoji */
   2072         xs_html_add(score,
   2073             xs_html_tag("span",
   2074                 xs_html_attr("title", L("Event")),
   2075                 xs_html_raw(" &#128197; ")));
   2076     }
   2077 
   2078     /* if it's a user from this same instance, add the score */
   2079     if (xs_startswith(id, srv_baseurl)) {
   2080         int n_likes  = object_likes_len(id);
   2081         int n_boosts = object_announces_len(id);
   2082 
   2083         /* alternate emojis: %d &#128077; %d &#128257; */
   2084         xs *s1 = xs_fmt("%d &#9733; %d &#8634;\n", n_likes, n_boosts);
   2085 
   2086         xs_html_add(score,
   2087             xs_html_raw(s1));
   2088     }
   2089 
   2090     xs *boosts = object_announces(id);
   2091 
   2092     if (xs_list_len(boosts)) {
   2093         /* if somebody boosted this, show as origin */
   2094         const char *p = xs_list_get(boosts, -1);
   2095         xs *actor_r = NULL;
   2096 
   2097         if (user && xs_list_in(boosts, user->md5) != -1) {
   2098             /* we boosted this */
   2099             xs_html_add(post_header,
   2100                 xs_html_tag("div",
   2101                     xs_html_attr("class", "snac-origin"),
   2102                     xs_html_tag("a",
   2103                         xs_html_attr("href", user->actor),
   2104                         xs_html_text(xs_dict_get(user->config, "name"))),
   2105                         xs_html_text(" "),
   2106                         xs_html_text(L("boosted"))));
   2107         }
   2108         else
   2109         if (valid_status(object_get_by_md5(p, &actor_r))) {
   2110             xs *name = actor_name(actor_r, proxy);
   2111 
   2112             if (!xs_is_null(name)) {
   2113                 xs *href = NULL;
   2114                 const char *id = xs_dict_get(actor_r, "id");
   2115                 int fwers = 0;
   2116                 int fwing = 0;
   2117 
   2118                 if (user != NULL) {
   2119                     fwers = follower_check(user, id);
   2120                     fwing = following_check(user, id);
   2121                 }
   2122 
   2123                 if (!read_only && (fwers || fwing))
   2124                     href = xs_fmt("%s/people#%s", user->actor, p);
   2125                 else
   2126                     href = xs_dup(id);
   2127 
   2128                 xs_html_add(post_header,
   2129                     xs_html_tag("div",
   2130                         xs_html_attr("class", "snac-origin"),
   2131                         xs_html_tag("a",
   2132                             xs_html_attr("href", href),
   2133                             xs_html_raw(name)), /* already sanitized */
   2134                             xs_html_text(" "),
   2135                             xs_html_text(L("boosted"))));
   2136             }
   2137         }
   2138     }
   2139 
   2140     if (user && strcmp(type, "Note") == 0) {
   2141         /* is the parent not here? */
   2142         const char *parent = get_in_reply_to(msg);
   2143 
   2144         if (!xs_is_null(parent) && *parent) {
   2145             if (!timeline_here(user, parent)) {
   2146                 xs_html_add(post_header,
   2147                     xs_html_tag("div",
   2148                         xs_html_attr("class", "snac-origin"),
   2149                         xs_html_text(L("in reply to")),
   2150                         xs_html_text(" "),
   2151                         xs_html_tag("a",
   2152                             xs_html_attr("href", parent),
   2153                             xs_html_text("ยป"))));
   2154             }
   2155         }
   2156     }
   2157 
   2158     xs_html_add(post_header,
   2159         html_msg_icon(read_only ? NULL : user, actor, msg, proxy, md5, lang));
   2160 
   2161     /** post content **/
   2162 
   2163     xs_html *snac_content_wrap = xs_html_tag("div",
   2164         xs_html_attr("class", "e-content snac-content"));
   2165 
   2166     if (xs_is_string(lang))
   2167         xs_html_add(snac_content_wrap,
   2168             xs_html_attr("lang", lang));
   2169 
   2170     xs_html_add(entry,
   2171         snac_content_wrap);
   2172 
   2173     if (!has_title && !xs_is_null(v = xs_dict_get(msg, "name"))) {
   2174         xs_html_add(snac_content_wrap,
   2175             xs_html_tag("h3",
   2176                 xs_html_attr("class", "snac-entry-title"),
   2177                 xs_html_text(v)));
   2178 
   2179         has_title = 1;
   2180     }
   2181 
   2182     xs_html *snac_content = NULL;
   2183 
   2184     v = xs_dict_get(msg, "summary");
   2185 
   2186     /* is it sensitive? */
   2187     if (xs_type(xs_dict_get(msg, "sensitive")) == XSTYPE_TRUE) {
   2188         if (xs_is_null(v) || *v == '\0')
   2189             v = "...";
   2190 
   2191         const char *cw = "";
   2192 
   2193         if (user) {
   2194             /* only show it when not in the public timeline and the config setting is "open" */
   2195             cw = xs_dict_get(user->config, "cw");
   2196             if (xs_is_null(cw) || read_only)
   2197                 cw = "";
   2198         }
   2199 
   2200         snac_content = xs_html_tag("details",
   2201             xs_html_attr(cw, NULL),
   2202             xs_html_tag("summary",
   2203                 xs_html_text(v),
   2204                 xs_html_text(L(" [SENSITIVE CONTENT]"))));
   2205     }
   2206     else {
   2207         /* print the summary as a header (sites like e.g. Friendica can contain one) */
   2208         if (!has_title && !xs_is_null(v) && *v) {
   2209             xs_html_add(snac_content_wrap,
   2210                 xs_html_tag("h3",
   2211                     xs_html_attr("class", "snac-entry-title"),
   2212                     xs_html_text(v)));
   2213 
   2214             has_title = 1;
   2215         }
   2216 
   2217         snac_content = xs_html_tag("div", NULL);
   2218     }
   2219 
   2220     xs_html_add(snac_content_wrap,
   2221         snac_content);
   2222 
   2223     {
   2224         /** build the content string **/
   2225         const char *content = xs_dict_get(msg, "content");
   2226 
   2227         if (xs_type(content) != XSTYPE_STRING) {
   2228             if (!xs_is_null(content))
   2229                 srv_archive_error("unexpected_content_xstype",
   2230                     "content field type", xs_stock(XSTYPE_DICT), msg);
   2231 
   2232             content = "";
   2233         }
   2234 
   2235         /* skip ugly line breaks at the beginning */
   2236         while (xs_startswith(content, "<br>"))
   2237             content += 4;
   2238 
   2239         xs *c = sanitize(content);
   2240 
   2241         /* do some tweaks to the content */
   2242         c = xs_replace_i(c, "\r", "");
   2243 
   2244         while (xs_endswith(c, "<br><br>"))
   2245             c = xs_crop_i(c, 0, -4);
   2246 
   2247         c = xs_replace_i(c, "<br><br>", "<p>");
   2248 
   2249         c = xs_str_cat(c, "<p>");
   2250 
   2251         /* replace the :shortnames: */
   2252         c = replace_shortnames(c, xs_dict_get(msg, "tag"), 2, proxy);
   2253 
   2254         /* Peertube videos content is in markdown */
   2255         const char *mtype = xs_dict_get(msg, "mediaType");
   2256         if (xs_type(mtype) == XSTYPE_STRING && strcmp(mtype, "text/markdown") == 0) {
   2257             /* a full conversion could be better */
   2258             c = xs_replace_i(c, "\r", "");
   2259             c = xs_replace_i(c, "\n", "<br>");
   2260         }
   2261 
   2262         /* c contains sanitized HTML */
   2263         xs_html_add(snac_content,
   2264             xs_html_raw(c));
   2265     }
   2266 
   2267     if (strcmp(type, "Question") == 0) { /** question content **/
   2268         const xs_list *oo = xs_dict_get(msg, "oneOf");
   2269         const xs_list *ao = xs_dict_get(msg, "anyOf");
   2270         const xs_list *p;
   2271         const xs_dict *v;
   2272         int closed = 0;
   2273         const char *f_closed = NULL;
   2274 
   2275         xs_html *poll = xs_html_tag("div", NULL);
   2276 
   2277         if (read_only)
   2278             closed = 1; /* non-identified page; show as closed */
   2279         else
   2280         if (user && xs_startswith(id, user->actor))
   2281             closed = 1; /* we questioned; closed for us */
   2282         else
   2283         if (user && was_question_voted(user, id))
   2284             closed = 1; /* we already voted; closed for us */
   2285 
   2286         if ((f_closed = xs_dict_get(msg, "closed")) != NULL) {
   2287             /* it has a closed date... but is it in the past? */
   2288             time_t t0 = time(NULL);
   2289             time_t t1 = xs_parse_iso_date(f_closed, 0);
   2290 
   2291             if (t1 < t0)
   2292                 closed = 2;
   2293         }
   2294 
   2295         /* get the appropriate list of options */
   2296         p = oo != NULL ? oo : ao;
   2297 
   2298         if (closed || user == NULL) {
   2299             /* closed poll */
   2300             xs_html *poll_result = xs_html_tag("table",
   2301                 xs_html_attr("class", "snac-poll-result"));
   2302             int c = 0;
   2303 
   2304             while (xs_list_next(p, &v, &c)) {
   2305                 const char *name       = xs_dict_get(v, "name");
   2306                 const xs_dict *replies = xs_dict_get(v, "replies");
   2307 
   2308                 if (xs_is_string(name) && xs_is_dict(replies)) {
   2309                     const char *ti = xs_number_str(xs_dict_get(replies, "totalItems"));
   2310 
   2311                     if (xs_is_string(ti))
   2312                         xs_html_add(poll_result,
   2313                             xs_html_tag("tr",
   2314                                 xs_html_tag("td",
   2315                                     xs_html_text(name),
   2316                                     xs_html_text(":")),
   2317                                 xs_html_tag("td",
   2318                                     xs_html_text(ti))));
   2319                 }
   2320             }
   2321 
   2322             xs_html_add(poll,
   2323                 poll_result);
   2324         }
   2325         else {
   2326             /* poll still active */
   2327             xs *vote_action = xs_fmt("%s/admin/vote", user->actor);
   2328             xs_html *form;
   2329             xs_html *poll_form = xs_html_tag("div",
   2330                 xs_html_attr("class", "snac-poll-form"),
   2331                 form = xs_html_tag("form",
   2332                     xs_html_attr("autocomplete", "off"),
   2333                     xs_html_attr("method", "post"),
   2334                     xs_html_attr("action", vote_action),
   2335                     xs_html_sctag("input",
   2336                         xs_html_attr("type", "hidden"),
   2337                         xs_html_attr("name", "actor"),
   2338                         xs_html_attr("value", actor)),
   2339                     xs_html_sctag("input",
   2340                         xs_html_attr("type", "hidden"),
   2341                         xs_html_attr("name", "irt"),
   2342                         xs_html_attr("value", id))));
   2343 
   2344             int c = 0;
   2345             while (xs_list_next(p, &v, &c)) {
   2346                 const char *name = xs_dict_get(v, "name");
   2347                 const xs_dict *replies = xs_dict_get(v, "replies");
   2348 
   2349                 if (name) {
   2350                     char *ti = (char *)xs_number_str(xs_dict_get(replies, "totalItems"));
   2351 
   2352                     xs_html *btn = xs_html_sctag("input",
   2353                             xs_html_attr("id", name),
   2354                             xs_html_attr("value", name),
   2355                             xs_html_attr("name", "question"));
   2356 
   2357                     if (!xs_is_null(oo)) {
   2358                         xs_html_add(btn,
   2359                             xs_html_attr("type", "radio"),
   2360                             xs_html_attr("required", "required"));
   2361                     }
   2362                     else
   2363                         xs_html_add(btn,
   2364                             xs_html_attr("type", "checkbox"));
   2365 
   2366                     xs_html_add(form,
   2367                         btn,
   2368                         xs_html_text(" "),
   2369                         xs_html_tag("span",
   2370                             xs_html_attr("title", ti),
   2371                             xs_html_text(name)),
   2372                         xs_html_sctag("br", NULL));
   2373                 }
   2374             }
   2375 
   2376             xs_html_add(form,
   2377                 xs_html_tag("p", NULL),
   2378                 xs_html_sctag("input",
   2379                     xs_html_attr("type", "submit"),
   2380                     xs_html_attr("class", "button"),
   2381                     xs_html_attr("value", L("Vote"))));
   2382 
   2383             xs_html_add(poll,
   2384                 poll_form);
   2385         }
   2386 
   2387         /* if it's *really* closed, say it */
   2388         if (closed == 2) {
   2389             xs_html_add(poll,
   2390                 xs_html_tag("p",
   2391                     xs_html_text(L("Closed"))));
   2392         }
   2393         else {
   2394             /* show when the poll closes */
   2395             const char *end_time = xs_dict_get(msg, "endTime");
   2396 
   2397             /* Pleroma does not have an endTime field;
   2398                it has a closed time in the future */
   2399             if (xs_is_null(end_time))
   2400                 end_time = xs_dict_get(msg, "closed");
   2401 
   2402             if (!xs_is_null(end_time)) {
   2403                 time_t t0 = time(NULL);
   2404                 time_t t1 = xs_parse_iso_date(end_time, 0);
   2405 
   2406                 if (t1 > 0 && t1 > t0) {
   2407                     time_t diff_time = t1 - t0;
   2408                     xs *tf = xs_str_time_diff(diff_time);
   2409                     char *p = tf;
   2410 
   2411                     /* skip leading zeros */
   2412                     for (; *p == '0' || *p == ':'; p++);
   2413 
   2414                     xs_html_add(poll,
   2415                         xs_html_tag("p",
   2416                             xs_html_text(L("Closes in")),
   2417                             xs_html_text(" "),
   2418                             xs_html_text(p)));
   2419                 }
   2420             }
   2421         }
   2422 
   2423         xs_html_add(snac_content,
   2424             poll);
   2425     }
   2426 
   2427     /** attachments **/
   2428     xs *attach = get_attachments(msg);
   2429 
   2430     {
   2431         /* make custom css for attachments easier */
   2432         xs_html *content_attachments = xs_html_tag("div",
   2433             xs_html_attr("class", "snac-content-attachments"));
   2434 
   2435         xs_html_add(snac_content,
   2436             content_attachments);
   2437 
   2438         const char *content = xs_dict_get(msg, "content");
   2439 
   2440         int c = 0;
   2441         const xs_dict *a;
   2442         while (xs_list_next(attach, &a, &c)) {
   2443             const char *type = xs_dict_get(a, "type");
   2444             const char *o_href = xs_dict_get(a, "href");
   2445             const char *name = xs_dict_get(a, "name");
   2446 
   2447             if (!xs_is_string(type) || !xs_is_string(o_href))
   2448                 continue;
   2449 
   2450             /* if this URL is already in the post content, skip */
   2451             if (content && xs_str_in(content, o_href) != -1)
   2452                 continue;
   2453 
   2454             if (strcmp(type, "image/svg+xml") == 0 && !xs_is_true(xs_dict_get(srv_config, "enable_svg")))
   2455                 continue;
   2456 
   2457             /* do this attachment include an icon? */
   2458             const xs_dict *icon = xs_dict_get(a, "icon");
   2459             if (xs_type(icon) == XSTYPE_DICT) {
   2460                 const char *icon_mtype = xs_dict_get(icon, "mediaType");
   2461                 const char *icon_url   = xs_dict_get(icon, "url");
   2462 
   2463                 if (icon_mtype && icon_url && xs_startswith(icon_mtype, "image/")) {
   2464                     xs_html_add(content_attachments,
   2465                         xs_html_tag("a",
   2466                             xs_html_attr("href", icon_url),
   2467                             xs_html_attr("target", "_blank"),
   2468                             xs_html_sctag("img",
   2469                                 xs_html_attr("loading", "lazy"),
   2470                                 xs_html_attr("src", icon_url))));
   2471                 }
   2472             }
   2473 
   2474             xs *href = make_url(o_href, proxy, 0);
   2475 
   2476             if (xs_startswith(type, "image/") || strcmp(type, "Image") == 0) {
   2477                 xs_html_add(content_attachments,
   2478                     xs_html_tag("a",
   2479                         xs_html_attr("href", href),
   2480                         xs_html_attr("target", "_blank"),
   2481                         xs_html_sctag("img",
   2482                             xs_html_attr("loading", "lazy"),
   2483                             xs_html_attr("src", href),
   2484                             xs_html_attr("alt", name),
   2485                             xs_html_attr("title", name))));
   2486             }
   2487             else
   2488             if (xs_startswith(type, "video/")) {
   2489                 xs_html_add(content_attachments,
   2490                     xs_html_tag("video",
   2491                         xs_html_attr("preload", "none"),
   2492                         xs_html_attr("style", "width: 100%"),
   2493                         xs_html_attr("class", "snac-embedded-video"),
   2494                         xs_html_attr("controls", NULL),
   2495                         xs_html_attr("src", href),
   2496                         xs_html_text(L("Video")),
   2497                         xs_html_text(": "),
   2498                         xs_html_tag("a",
   2499                             xs_html_attr("href", href),
   2500                             xs_html_text(name))));
   2501             }
   2502             else
   2503             if (xs_startswith(type, "audio/")) {
   2504                 xs_html_add(content_attachments,
   2505                     xs_html_tag("audio",
   2506                         xs_html_attr("preload", "none"),
   2507                         xs_html_attr("style", "width: 100%"),
   2508                         xs_html_attr("class", "snac-embedded-audio"),
   2509                         xs_html_attr("controls", NULL),
   2510                         xs_html_attr("src", href),
   2511                         xs_html_text(L("Audio")),
   2512                         xs_html_text(": "),
   2513                         xs_html_tag("a",
   2514                             xs_html_attr("href", href),
   2515                             xs_html_text(name))));
   2516             }
   2517             else
   2518             if (strcmp(type, "Link") == 0) {
   2519                 xs_html_add(content_attachments,
   2520                     xs_html_tag("p",
   2521                         xs_html_tag("a",
   2522                             xs_html_attr("href", o_href),
   2523                             xs_html_text(href))));
   2524 
   2525                 /* do not generate an Alt... */
   2526                 name = NULL;
   2527             }
   2528             else {
   2529                 xs *d_href = xs_dup(o_href);
   2530                 if (strlen(d_href) > 64) {
   2531                     d_href[64] = '\0';
   2532                     d_href = xs_str_cat(d_href, "...");
   2533                 }
   2534 
   2535                 xs_html_add(content_attachments,
   2536                     xs_html_tag("p",
   2537                         xs_html_tag("a",
   2538                             xs_html_attr("href", o_href),
   2539                             xs_html_text(L("Attachment")),
   2540                             xs_html_text(": "),
   2541                             xs_html_text(d_href))));
   2542 
   2543                 /* do not generate an Alt... */
   2544                 name = NULL;
   2545             }
   2546 
   2547             if (name != NULL && *name) {
   2548                 xs_html_add(content_attachments,
   2549                     xs_html_tag("p",
   2550                         xs_html_attr("class", "snac-alt-text"),
   2551                         xs_html_tag("details",
   2552                             xs_html_tag("summary",
   2553                                 xs_html_text(L("Alt..."))),
   2554                             xs_html_text(name))));
   2555             }
   2556         }
   2557     }
   2558 
   2559     /* has this message an audience (i.e., comes from a channel or community)? */
   2560     const char *audience = xs_dict_get(msg, "audience");
   2561     if (strcmp(type, "Page") == 0 && !xs_is_null(audience)) {
   2562         xs_html *au_tag = xs_html_tag("p",
   2563             xs_html_text("("),
   2564             xs_html_tag("a",
   2565                 xs_html_attr("href", audience),
   2566                 xs_html_attr("title", L("Source channel or community")),
   2567                 xs_html_text(audience)),
   2568             xs_html_text(")"));
   2569 
   2570         xs_html_add(snac_content_wrap,
   2571             au_tag);
   2572     }
   2573 
   2574     /* does it have a location? */
   2575     const xs_dict *location = xs_dict_get(msg, "location");
   2576     if (xs_type(location) == XSTYPE_DICT) {
   2577         const xs_number *latitude = xs_dict_get(location, "latitude");
   2578         const xs_number *longitude = xs_dict_get(location, "longitude");
   2579         const char *name = xs_dict_get(location, "name");
   2580         const char *address = xs_dict_get(location, "address");
   2581         xs *label_list = xs_list_new();
   2582 
   2583         if (xs_type(name) == XSTYPE_STRING)
   2584             label_list = xs_list_append(label_list, name);
   2585         if (xs_type(address) == XSTYPE_STRING)
   2586             label_list = xs_list_append(label_list, address);
   2587 
   2588         if (xs_list_len(label_list)) {
   2589             const char *url = xs_dict_get(location, "url");
   2590             xs *label = xs_join(label_list, ", ");
   2591 
   2592             if (xs_type(url) == XSTYPE_STRING) {
   2593                 xs_html_add(snac_content_wrap,
   2594                     xs_html_tag("p",
   2595                         xs_html_text(L("Location: ")),
   2596                         xs_html_tag("a",
   2597                             xs_html_attr("href", url),
   2598                             xs_html_attr("target", "_blank"),
   2599                             xs_html_text(label))));
   2600             }
   2601             else
   2602             if (!xs_is_null(latitude) && !xs_is_null(longitude)) {
   2603                 xs *url = xs_fmt("https://openstreetmap.org/search/?query=%s,%s",
   2604                     xs_number_str(latitude), xs_number_str(longitude));
   2605 
   2606                 xs_html_add(snac_content_wrap,
   2607                     xs_html_tag("p",
   2608                         xs_html_text(L("Location: ")),
   2609                         xs_html_tag("a",
   2610                             xs_html_attr("href", url),
   2611                             xs_html_attr("target", "_blank"),
   2612                             xs_html_text(label))));
   2613             }
   2614             else
   2615                 xs_html_add(snac_content_wrap,
   2616                     xs_html_tag("p",
   2617                         xs_html_text(L("Location: ")),
   2618                         xs_html_text(label)));
   2619         }
   2620     }
   2621 
   2622     if (strcmp(type, "Event") == 0) { /** Event start and end times **/
   2623         const char *s_time = xs_dict_get(msg, "startTime");
   2624 
   2625         if (xs_is_string(s_time) && strlen(s_time) > 20) {
   2626             const char *e_time = xs_dict_get(msg, "endTime");
   2627             const char *tz     = xs_dict_get(msg, "timezone");
   2628 
   2629             xs *s = xs_replace_i(xs_dup(s_time), "T", " ");
   2630             xs *e = NULL;
   2631 
   2632             if (xs_is_string(e_time) && strlen(e_time) > 20)
   2633                 e = xs_replace_i(xs_dup(e_time), "T", " ");
   2634 
   2635             /* if the event has a timezone, crop the offsets */
   2636             if (xs_is_string(tz)) {
   2637                 s = xs_crop_i(s, 0, 19);
   2638 
   2639                 if (e)
   2640                     e = xs_crop_i(e, 0, 19);
   2641             }
   2642             else
   2643                 tz = "";
   2644 
   2645             /* if start and end share the same day, crop it from the end */
   2646             if (e && memcmp(s, e, 11) == 0)
   2647                 e = xs_crop_i(e, 11, 0);
   2648 
   2649             if (e)
   2650                 s = xs_str_cat(s, " / ", e);
   2651 
   2652             if (*tz)
   2653                 s = xs_str_cat(s, " (", tz, ")");
   2654 
   2655             /* replace ugly decimals */
   2656             s = xs_replace_i(s, ".000", "");
   2657 
   2658             xs_html_add(snac_content_wrap,
   2659                 xs_html_tag("p",
   2660                     xs_html_text(L("Time: ")),
   2661                     xs_html_text(s)));
   2662         }
   2663     }
   2664 
   2665     /* show all hashtags that has not been shown previously in the content */
   2666     const xs_list *tags = xs_dict_get(msg, "tag");
   2667     const char *o_content = xs_dict_get_def(msg, "content", "");
   2668 
   2669     if (xs_is_string(o_content) && xs_is_list(tags) && xs_list_len(tags)) {
   2670         xs *content = xs_utf8_to_lower(o_content);
   2671         const xs_dict *tag;
   2672 
   2673         xs_html *add_hashtags = xs_html_tag("ul",
   2674             xs_html_attr("class", "snac-more-hashtags"));
   2675 
   2676         xs_list_foreach(tags, tag) {
   2677             const char *type = xs_dict_get(tag, "type");
   2678 
   2679             if (xs_is_string(type) && strcmp(type, "Hashtag") == 0) {
   2680                 const char *o_href = xs_dict_get(tag, "href");
   2681                 const char *name   = xs_dict_get(tag, "name");
   2682 
   2683                 if (xs_is_string(o_href) && xs_is_string(name)) {
   2684                     xs *href = xs_utf8_to_lower(o_href);
   2685 
   2686                     if (xs_str_in(content, href) == -1 && xs_str_in(content, name) == -1) {
   2687                         /* not in the content: add here */
   2688                         xs_html_add(add_hashtags,
   2689                             xs_html_tag("li",
   2690                                 xs_html_tag("a",
   2691                                     xs_html_attr("href", href),
   2692                                     xs_html_text(name),
   2693                                     xs_html_text(" "))));
   2694                     }
   2695                 }
   2696             }
   2697         }
   2698 
   2699         xs_html_add(snac_content_wrap,
   2700             add_hashtags);
   2701     }
   2702 
   2703     /** controls **/
   2704 
   2705     if (!read_only && user) {
   2706         xs_html_add(entry,
   2707             html_entry_controls(user, actor, msg, md5));
   2708     }
   2709 
   2710     /** children **/
   2711     if (!hide_children) {
   2712         xs *children = object_children(id);
   2713         int left     = xs_list_len(children);
   2714 
   2715         if (left) {
   2716             xs_html *ch_details = xs_html_tag("details",
   2717                 xs_html_attr(collapse_threads ? "" : "open", NULL),
   2718                 xs_html_tag("summary",
   2719                     xs_html_text("...")));
   2720 
   2721             xs_html_add(entry,
   2722                 ch_details);
   2723 
   2724             xs_html *fch_container = xs_html_tag("div",
   2725                 xs_html_attr("class", "snac-thread-cont"));
   2726 
   2727             xs_html_add(ch_details,
   2728                 fch_container);
   2729 
   2730             xs_html *ch_container = xs_html_tag("div",
   2731                 xs_html_attr("class", level < 4 ? "snac-children" : "snac-children-too-deep"));
   2732 
   2733             xs_html_add(ch_details,
   2734                 ch_container);
   2735 
   2736             xs_html *ch_older = NULL;
   2737             if (left > 3) {
   2738                 xs_html_add(ch_container,
   2739                     ch_older = xs_html_tag("details",
   2740                         xs_html_tag("summary",
   2741                             xs_html_text(L("Older...")))));
   2742             }
   2743 
   2744             int ctxt = 0;
   2745             const char *cmd5;
   2746             int cnt = 0;
   2747             int o_cnt = 0;
   2748             int f_cnt = 0;
   2749 
   2750             /* get the first child */
   2751             xs_list_next(children, &cmd5, &ctxt);
   2752             xs *f_chd = NULL;
   2753 
   2754             if (user)
   2755                 timeline_get_by_md5(user, cmd5, &f_chd);
   2756             else
   2757                 object_get_by_md5(cmd5, &f_chd);
   2758 
   2759             if (f_chd != NULL && xs_is_null(xs_dict_get(f_chd, "name"))) {
   2760                 const char *p_author = get_atto(msg);
   2761                 const char *author   = get_atto(f_chd);
   2762 
   2763                 /* is the first child from the same author? */
   2764                 if (xs_is_string(p_author) && xs_is_string(author) && strcmp(p_author, author) == 0) {
   2765                     /* then, don't add it to the children container,
   2766                        so that it appears unindented just before the parent
   2767                        like a fucking Twitter-like thread */
   2768                     xs_html_add(fch_container,
   2769                         html_entry(user, f_chd, read_only, level + 1, cmd5, hide_children));
   2770 
   2771                     cnt++;
   2772                     f_cnt++;
   2773                     left--;
   2774                 }
   2775                 else
   2776                     ctxt = 0; /* restart from the beginning */
   2777             }
   2778 
   2779             while (xs_list_next(children, &cmd5, &ctxt)) {
   2780                 xs *chd = NULL;
   2781 
   2782                 if (user)
   2783                     timeline_get_by_md5(user, cmd5, &chd);
   2784                 else
   2785                     object_get_by_md5(cmd5, &chd);
   2786 
   2787                 if (chd != NULL) {
   2788                     if (xs_is_null(xs_dict_get(chd, "name"))) {
   2789                         xs_html *che = html_entry(user, chd, read_only,
   2790                             level + 1, cmd5, hide_children);
   2791 
   2792                         if (che != NULL) {
   2793                             if (left > 3) {
   2794                                 xs_html_add(ch_older,
   2795                                     che);
   2796 
   2797                                 o_cnt++;
   2798                             }
   2799                             else
   2800                                 xs_html_add(ch_container,
   2801                                     che);
   2802 
   2803                             cnt++;
   2804                         }
   2805                     }
   2806 
   2807                     left--;
   2808                 }
   2809                 else
   2810                     srv_debug(2, xs_fmt("cannot read child %s", cmd5));
   2811             }
   2812 
   2813             /* if no children were finally added, hide the details */
   2814             if (cnt == 0)
   2815                 xs_html_add(ch_details,
   2816                     xs_html_attr("style", "display: none"));
   2817 
   2818             if (o_cnt == 0 && ch_older)
   2819                 xs_html_add(ch_older,
   2820                     xs_html_attr("style", "display: none"));
   2821 
   2822             if (f_cnt == 0)
   2823                 xs_html_add(fch_container,
   2824                     xs_html_attr("style", "display: none"));
   2825         }
   2826     }
   2827 
   2828     /* add an invisible hr, to help differentiate between posts in text browsers */
   2829     xs_html_add(entry_top,
   2830         xs_html_sctag("hr",
   2831             xs_html_attr("hidden", NULL)));
   2832 
   2833     return entry_top;
   2834 }
   2835 
   2836 
   2837 xs_html *html_footer(const snac *user)
   2838 {
   2839     return xs_html_tag("div",
   2840         xs_html_attr("class", "snac-footer"),
   2841         xs_html_tag("a",
   2842             xs_html_attr("href", srv_baseurl),
   2843             xs_html_text(L("about this site"))),
   2844         xs_html_text(" - "),
   2845         xs_html_text(L("powered by ")),
   2846         xs_html_tag("a",
   2847             xs_html_attr("href", WHAT_IS_SNAC_URL),
   2848             xs_html_tag("abbr",
   2849                 xs_html_attr("title", "Social Networks Are Crap"),
   2850                 xs_html_text("snac"))));
   2851 }
   2852 
   2853 
   2854 xs_str *html_timeline(snac *user, const xs_list *list, int read_only,
   2855                       int skip, int show, int show_more,
   2856                       const char *title, const char *page,
   2857                       int utl, const char *error)
   2858 /* returns the HTML for the timeline */
   2859 {
   2860     xs_list *p = (xs_list *)list;
   2861     const char *v;
   2862     double t = ftime();
   2863     int hide_children = xs_is_true(xs_dict_get(srv_config, "strict_public_timelines")) && read_only;
   2864 
   2865     xs *desc = NULL;
   2866     xs *alternate = NULL;
   2867 
   2868     if (xs_list_len(list) == 1) {
   2869         /* only one element? pick the description from the source */
   2870         const char *id = xs_list_get(list, 0);
   2871         xs *d = NULL;
   2872         object_get_by_md5(id, &d);
   2873         const char *sc = xs_dict_get(d, "sourceContent");
   2874         if (d && sc != NULL)
   2875             desc = xs_dup(sc);
   2876 
   2877         alternate = xs_dup(xs_dict_get(d, "id"));
   2878     }
   2879 
   2880     xs_html *head;
   2881     xs_html *body;
   2882 
   2883     if (user) {
   2884         head = html_user_head(user, desc, alternate);
   2885         body = html_user_body(user, read_only);
   2886     }
   2887     else {
   2888         head = html_instance_head();
   2889         body = html_instance_body();
   2890     }
   2891 
   2892     xs_html *html = xs_html_tag("html",
   2893         head,
   2894         body);
   2895 
   2896     if (user && !read_only)
   2897         xs_html_add(body,
   2898             html_top_controls(user));
   2899 
   2900     if (error != NULL) {
   2901         xs_html_add(body,
   2902             xs_html_tag("dialog",
   2903                 xs_html_attr("open", NULL),
   2904                 xs_html_tag("p",
   2905                     xs_html_text(error)),
   2906                 xs_html_tag("form",
   2907                     xs_html_attr("method", "dialog"),
   2908                     xs_html_sctag("input",
   2909                         xs_html_attr("type", "submit"),
   2910                         xs_html_attr("value", L("Dismiss"))))));
   2911     }
   2912 
   2913     /* show links to the available lists */
   2914     if (user && !read_only) {
   2915         xs_html *lol = xs_html_tag("ul",
   2916             xs_html_attr("class", "snac-list-of-lists"));
   2917         xs_html_add(body, lol);
   2918 
   2919         xs *lists = list_maint(user, NULL, 0); /* get list of lists */
   2920 
   2921         int ct = 0;
   2922         const char *v;
   2923 
   2924         while (xs_list_next(lists, &v, &ct)) {
   2925             const char *lname = xs_list_get(v, 1);
   2926             xs *url = xs_fmt("%s/list/%s", user->actor, xs_list_get(v, 0));
   2927             xs *ttl = xs_fmt(L("Timeline for list '%s'"), lname);
   2928 
   2929             xs_html_add(lol,
   2930                 xs_html_tag("li",
   2931                     xs_html_tag("a",
   2932                         xs_html_attr("href", url),
   2933                         xs_html_attr("class", "snac-list-link"),
   2934                         xs_html_attr("title", ttl),
   2935                         xs_html_text(lname))));
   2936         }
   2937 
   2938         {
   2939             /* show the list of pinned posts */
   2940             xs *url = xs_fmt("%s/pinned", user->actor);
   2941             xs_html_add(lol,
   2942                 xs_html_tag("li",
   2943                     xs_html_tag("a",
   2944                         xs_html_attr("href", url),
   2945                         xs_html_attr("class", "snac-list-link"),
   2946                         xs_html_attr("title", L("Pinned posts")),
   2947                         xs_html_text(L("pinned")))));
   2948         }
   2949 
   2950         {
   2951             /* show the list of bookmarked posts */
   2952             xs *url = xs_fmt("%s/bookmarks", user->actor);
   2953             xs_html_add(lol,
   2954                 xs_html_tag("li",
   2955                     xs_html_tag("a",
   2956                         xs_html_attr("href", url),
   2957                         xs_html_attr("class", "snac-list-link"),
   2958                         xs_html_attr("title", L("Bookmarked posts")),
   2959                         xs_html_text(L("bookmarks")))));
   2960         }
   2961 
   2962         {
   2963             /* show the list of drafts */
   2964             xs *url = xs_fmt("%s/drafts", user->actor);
   2965             xs_html_add(lol,
   2966                 xs_html_tag("li",
   2967                     xs_html_tag("a",
   2968                         xs_html_attr("href", url),
   2969                         xs_html_attr("class", "snac-list-link"),
   2970                         xs_html_attr("title", L("Post drafts")),
   2971                         xs_html_text(L("drafts")))));
   2972         }
   2973 
   2974         {
   2975             /* show the list of scheduled posts */
   2976             xs *url = xs_fmt("%s/sched", user->actor);
   2977             xs_html_add(lol,
   2978                 xs_html_tag("li",
   2979                     xs_html_tag("a",
   2980                         xs_html_attr("href", url),
   2981                         xs_html_attr("class", "snac-list-link"),
   2982                         xs_html_attr("title", L("Scheduled posts")),
   2983                         xs_html_text(L("scheduled posts")))));
   2984         }
   2985 
   2986         /* the list of followed hashtags */
   2987         const char *followed_hashtags = xs_dict_get(user->config, "followed_hashtags");
   2988 
   2989         if (xs_is_list(followed_hashtags) && xs_list_len(followed_hashtags)) {
   2990             xs_html *loht = xs_html_tag("ul",
   2991                 xs_html_attr("class", "snac-list-of-lists"));
   2992             xs_html_add(body, loht);
   2993 
   2994             const char *ht;
   2995 
   2996             xs_list_foreach(followed_hashtags, ht) {
   2997                 xs *url = xs_fmt("%s/admin?q=%s", user->actor, ht);
   2998                 url = xs_replace_i(url, "#", "%23");
   2999 
   3000                 xs_html_add(loht,
   3001                     xs_html_tag("li",
   3002                         xs_html_tag("a",
   3003                             xs_html_attr("href", url),
   3004                             xs_html_attr("class", "snac-list-link"),
   3005                             xs_html_text(ht))));
   3006             }
   3007         }
   3008     }
   3009 
   3010     xs_html_add(body,
   3011         xs_html_tag("a",
   3012             xs_html_attr("name", "snac-posts")));
   3013 
   3014     xs_html *posts = xs_html_tag("div",
   3015         xs_html_attr("class", "snac-posts"));
   3016 
   3017     if (title) {
   3018         xs_html_add(posts,
   3019             xs_html_tag("h2",
   3020                 xs_html_attr("class", "snac-header"),
   3021                 xs_html_text(title)));
   3022     }
   3023 
   3024     xs_html_add(body,
   3025         posts);
   3026 
   3027     int mark_shown = 0;
   3028 
   3029     while (xs_list_iter(&p, &v)) {
   3030         xs *msg = NULL;
   3031         int status;
   3032 
   3033         /* "already seen" mark? */
   3034         if (strcmp(v, MD5_ALREADY_SEEN_MARK) == 0) {
   3035             if (skip == 0 && !mark_shown) {
   3036                 xs *s = xs_fmt("%s/admin", user->actor);
   3037 
   3038                 xs_html_add(posts,
   3039                     xs_html_tag("div",
   3040                         xs_html_attr("class", "snac-no-more-unseen-posts"),
   3041                         xs_html_text(L("No more unseen posts")),
   3042                         xs_html_text(" - "),
   3043                         xs_html_tag("a",
   3044                             xs_html_attr("href", s),
   3045                             xs_html_text(L("Back to top")))));
   3046             }
   3047 
   3048             mark_shown = 1;
   3049 
   3050             continue;
   3051         }
   3052 
   3053         if (utl && user && !is_pinned_by_md5(user, v))
   3054             status = timeline_get_by_md5(user, v, &msg);
   3055         else
   3056             status = object_get_by_md5(v, &msg);
   3057 
   3058         if (!valid_status(status))
   3059             continue;
   3060 
   3061         /* if it's an instance page, discard messages from private users */
   3062         if (user == NULL && is_msg_from_private_user(msg))
   3063             continue;
   3064 
   3065         /* is this message a non-public reply? */
   3066         if (user != NULL && !is_msg_public(msg)) {
   3067             const char *irt = get_in_reply_to(msg);
   3068 
   3069             /* is it a reply to something not in the storage? */
   3070             if (!xs_is_null(irt) && !object_here(irt)) {
   3071                 /* is it for me? */
   3072                 const xs_list *to = xs_dict_get_def(msg, "to", xs_stock(XSTYPE_LIST));
   3073                 const xs_list *cc = xs_dict_get_def(msg, "cc", xs_stock(XSTYPE_LIST));
   3074 
   3075                 if (xs_list_in(to, user->actor) == -1 && xs_list_in(cc, user->actor) == -1) {
   3076                     snac_debug(user, 1, xs_fmt("skipping non-public reply to an unknown post %s", v));
   3077                     continue;
   3078                 }
   3079             }
   3080         }
   3081 
   3082         xs_html *entry = html_entry(user, msg, read_only, 0, v, (user && !hide_children) ? 0 : 1);
   3083 
   3084         if (entry != NULL)
   3085             xs_html_add(posts,
   3086                 entry);
   3087     }
   3088 
   3089     if (list && user && read_only) {
   3090         /** history **/
   3091         if (xs_type(xs_dict_get(srv_config, "disable_history")) != XSTYPE_TRUE) {
   3092             xs_html *ul = xs_html_tag("ul", NULL);
   3093 
   3094             xs_html *history = xs_html_tag("div",
   3095                 xs_html_attr("class", "snac-history"),
   3096                 xs_html_tag("p",
   3097                     xs_html_attr("class", "snac-history-title"),
   3098                     xs_html_text(L("History"))),
   3099                     ul);
   3100 
   3101             xs *list = history_list(user);
   3102             xs_list *p = list;
   3103             const char *v;
   3104 
   3105             while (xs_list_iter(&p, &v)) {
   3106                 xs *fn  = xs_replace(v, ".html", "");
   3107                 xs *url = xs_fmt("%s/h/%s", user->actor, v);
   3108 
   3109                 xs_html_add(ul,
   3110                     xs_html_tag("li",
   3111                         xs_html_tag("a",
   3112                             xs_html_attr("href", url),
   3113                             xs_html_text(fn))));
   3114             }
   3115 
   3116             xs_html_add(body,
   3117                 history);
   3118         }
   3119     }
   3120 
   3121     {
   3122         xs *s1 = xs_fmt("\n<!-- %lf seconds -->\n", ftime() - t);
   3123         xs_html_add(body,
   3124             xs_html_raw(s1));
   3125     }
   3126 
   3127     if (show_more) {
   3128         xs *m  = NULL;
   3129         xs *ss = xs_fmt("skip=%d&show=%d", skip + show, show);
   3130 
   3131         xs *url = xs_dup(user == NULL ? srv_baseurl : user->actor);
   3132 
   3133         if (page != NULL)
   3134             url = xs_str_cat(url, page);
   3135 
   3136         if (xs_str_in(url, "?") != -1)
   3137             m = xs_fmt("%s&%s", url, ss);
   3138         else
   3139             m = xs_fmt("%s?%s", url, ss);
   3140 
   3141         xs_html *more_links = xs_html_tag("p",
   3142             xs_html_tag("a",
   3143                 xs_html_attr("href", url),
   3144                 xs_html_attr("name", "snac-more"),
   3145                 xs_html_text(L("Back to top"))),
   3146             xs_html_text(" - "),
   3147             xs_html_tag("a",
   3148                 xs_html_attr("href", m),
   3149                 xs_html_attr("name", "snac-more"),
   3150                 xs_html_text(L("More..."))));
   3151 
   3152         xs_html_add(body,
   3153             more_links);
   3154     }
   3155 
   3156     xs_html_add(body,
   3157         html_footer(user));
   3158 
   3159     return xs_html_render_s(html, "<!DOCTYPE html>\n");
   3160 }
   3161 
   3162 
   3163 xs_html *html_people_list(snac *user, xs_list *list, const char *header, const char *t, const char *proxy)
   3164 {
   3165     xs_html *snac_posts;
   3166     xs_html *people = xs_html_tag("div",
   3167         xs_html_tag("h2",
   3168             xs_html_attr("class", "snac-header"),
   3169             xs_html_text(header)),
   3170         snac_posts = xs_html_tag("details",
   3171                 xs_html_attr("open", NULL),
   3172                 xs_html_tag("summary",
   3173                     xs_html_text("..."))));
   3174 
   3175     xs *redir = xs_fmt("%s/people", user->actor);
   3176 
   3177     const char *actor_id;
   3178 
   3179     xs_list_foreach(list, actor_id) {
   3180         const char *md5 = xs_md5(actor_id);
   3181         xs *actor = NULL;
   3182 
   3183         if (valid_status(actor_get(actor_id, &actor))) {
   3184             xs_html *snac_post = xs_html_tag("div",
   3185                 xs_html_attr("class", "snac-post"),
   3186                 xs_html_tag("a",
   3187                     xs_html_attr("name", md5)),
   3188                 xs_html_tag("div",
   3189                     xs_html_attr("class", "snac-post-header"),
   3190                     html_actor_icon(user, actor, xs_dict_get(actor, "published"),
   3191                                     NULL, NULL, 0, 1, proxy, NULL, NULL)));
   3192 
   3193             /* content (user bio) */
   3194             const char *c = xs_dict_get(actor, "summary");
   3195 
   3196             if (!xs_is_null(c)) {
   3197                 xs *sc = sanitize(c);
   3198                 sc = replace_shortnames(sc, xs_dict_get(actor, "tag"), 2, proxy);
   3199 
   3200                 xs_html *snac_content = xs_html_tag("div",
   3201                     xs_html_attr("class", "snac-content"));
   3202 
   3203                 if (xs_startswith(sc, "<p>"))
   3204                     xs_html_add(snac_content,
   3205                         xs_html_raw(sc)); /* already sanitized */
   3206                 else
   3207                     xs_html_add(snac_content,
   3208                         xs_html_tag("p",
   3209                             xs_html_raw(sc))); /* already sanitized */
   3210 
   3211                 xs_html_add(snac_post, snac_content);
   3212             }
   3213 
   3214             /* buttons */
   3215             xs *btn_form_action = xs_fmt("%s/admin/action", user->actor);
   3216 
   3217             xs_html *snac_controls = xs_html_tag("div",
   3218                 xs_html_attr("class",   "snac-controls"));
   3219 
   3220             xs_html *form = xs_html_tag("form",
   3221                 xs_html_attr("autocomplete",    "off"),
   3222                 xs_html_attr("method",          "post"),
   3223                 xs_html_attr("action",          btn_form_action),
   3224                 xs_html_sctag("input",
   3225                     xs_html_attr("type",    "hidden"),
   3226                     xs_html_attr("name",    "actor"),
   3227                     xs_html_attr("value",   actor_id)),
   3228                 xs_html_sctag("input",
   3229                     xs_html_attr("type",    "hidden"),
   3230                     xs_html_attr("name",    "hard-redir"),
   3231                     xs_html_attr("value",   redir)),
   3232                 xs_html_sctag("input",
   3233                     xs_html_attr("type",    "hidden"),
   3234                     xs_html_attr("name",    "actor-form"),
   3235                     xs_html_attr("value",   "yes")));
   3236 
   3237             xs_html_add(snac_controls, form);
   3238 
   3239             if (following_check(user, actor_id)) {
   3240                 xs_html_add(form,
   3241                     html_button("unfollow", L("Unfollow"),
   3242                                 L("Stop following this user's activity")));
   3243 
   3244                 if (is_limited(user, actor_id))
   3245                     xs_html_add(form,
   3246                         html_button("unlimit", L("Unlimit"),
   3247                                 L("Allow announces (boosts) from this user")));
   3248                 else
   3249                     xs_html_add(form,
   3250                         html_button("limit", L("Limit"),
   3251                                 L("Block announces (boosts) from this user")));
   3252             }
   3253             else {
   3254                 xs_html_add(form,
   3255                     html_button("follow", L("Follow"),
   3256                                 L("Start following this user's activity")));
   3257 
   3258                 if (follower_check(user, actor_id))
   3259                     xs_html_add(form,
   3260                         html_button("delete", L("Delete"), L("Delete this user")));
   3261             }
   3262 
   3263             if (pending_check(user, actor_id)) {
   3264                 xs_html_add(form,
   3265                     html_button("approve", L("Approve"),
   3266                                 L("Approve this follow request")));
   3267 
   3268                 xs_html_add(form,
   3269                     html_button("discard", L("Discard"), L("Discard this follow request")));
   3270             }
   3271 
   3272             if (is_muted(user, actor_id))
   3273                 xs_html_add(form,
   3274                     html_button("unmute", L("Unmute"),
   3275                                 L("Stop blocking activities from this user")));
   3276             else
   3277                 xs_html_add(form,
   3278                     html_button("mute", L("MUTE"),
   3279                                 L("Block any activity from this user")));
   3280 
   3281             /* the post textarea */
   3282             xs *dm_div_id  = xs_fmt("%s_%s_dm", md5, t);
   3283             xs *dm_form_id = xs_fmt("%s_reply_form", md5);
   3284 
   3285             xs_html_add(snac_controls,
   3286                 xs_html_tag("p", NULL),
   3287                 html_note(user, L("Direct Message..."),
   3288                     dm_div_id, dm_form_id,
   3289                     "", "",
   3290                     NULL, actor_id,
   3291                     xs_stock(XSTYPE_FALSE), "",
   3292                     xs_stock(XSTYPE_FALSE), NULL,
   3293                     NULL, 0, NULL, NULL, 0, NULL),
   3294                 xs_html_tag("p", NULL));
   3295 
   3296             xs_html_add(snac_post, snac_controls);
   3297 
   3298             xs_html_add(snac_posts, snac_post);
   3299         }
   3300     }
   3301 
   3302     return people;
   3303 }
   3304 
   3305 
   3306 xs_str *html_people(snac *user)
   3307 {
   3308     const char *proxy = NULL;
   3309 
   3310     if (xs_is_true(xs_dict_get(srv_config, "proxy_media")))
   3311         proxy = user->actor;
   3312 
   3313     xs *wing = following_list(user);
   3314     xs *wers = follower_list(user);
   3315     xs *pending = pending_list(user);
   3316 
   3317     xs_html *lists = xs_html_tag("div",
   3318         xs_html_attr("class", "snac-posts"));
   3319 
   3320     if (xs_list_len(pending) || xs_is_true(xs_dict_get(user->config, "approve_followers"))) {
   3321         xs_html_add(lists,
   3322             html_people_list(user, pending, L("Pending follow confirmations"), "p", proxy));
   3323     }
   3324 
   3325     xs_html_add(lists,
   3326         html_people_list(user, wing, L("People you follow"), "i", proxy),
   3327         html_people_list(user, wers, L("People that follow you"), "e", proxy));
   3328 
   3329     xs_html *html = xs_html_tag("html",
   3330         html_user_head(user, NULL, NULL),
   3331         xs_html_add(html_user_body(user, 0),
   3332             lists,
   3333             html_footer(user)));
   3334 
   3335     return xs_html_render_s(html, "<!DOCTYPE html>\n");
   3336 }
   3337 
   3338 
   3339 xs_str *html_notifications(snac *user, int skip, int show)
   3340 {
   3341     const char *proxy = NULL;
   3342 
   3343     if (xs_is_true(xs_dict_get(srv_config, "proxy_media")))
   3344         proxy = user->actor;
   3345 
   3346     xs *n_list = notify_list(user, skip, show);
   3347     xs *n_time = notify_check_time(user, 0);
   3348 
   3349     xs_html *body = html_user_body(user, 0);
   3350 
   3351     xs_html *html = xs_html_tag("html",
   3352         html_user_head(user, NULL, NULL),
   3353         body);
   3354 
   3355     xs *clear_all_action = xs_fmt("%s/admin/clear-notifications", user->actor);
   3356 
   3357     xs_html_add(body,
   3358         xs_html_tag("form",
   3359             xs_html_attr("autocomplete", "off"),
   3360             xs_html_attr("method",       "post"),
   3361             xs_html_attr("action",       clear_all_action),
   3362             xs_html_attr("id",           "clear"),
   3363             xs_html_sctag("input",
   3364                 xs_html_attr("type",     "submit"),
   3365                 xs_html_attr("class",    "snac-btn-like"),
   3366                 xs_html_attr("value",    L("Clear all")))));
   3367 
   3368     xs_html *noti_new = NULL;
   3369     xs_html *noti_seen = NULL;
   3370 
   3371     xs_html *posts = xs_html_tag("div",
   3372         xs_html_attr("class", "snac-posts"));
   3373     xs_html_add(body, posts);
   3374 
   3375     xs_set rep;
   3376     xs_set_init(&rep);
   3377 
   3378     /* dict to store previous notification labels */
   3379     xs *admiration_labels = xs_dict_new();
   3380 
   3381     const xs_str *v;
   3382 
   3383     xs_list_foreach(n_list, v) {
   3384         xs *noti = notify_get(user, v);
   3385 
   3386         if (noti == NULL)
   3387             continue;
   3388 
   3389         xs *obj = NULL;
   3390         const char *type  = xs_dict_get(noti, "type");
   3391         const char *utype = xs_dict_get(noti, "utype");
   3392         const char *id    = xs_dict_get(noti, "objid");
   3393         const char *date  = xs_dict_get(noti, "date");
   3394         const char *id2   = xs_dict_get_path(noti, "msg.id");
   3395         xs *wrk = NULL;
   3396 
   3397         if (xs_is_null(id))
   3398             continue;
   3399 
   3400         if (is_hidden(user, id))
   3401             continue;
   3402 
   3403         if (xs_is_string(id2) && xs_set_add(&rep, id2) != 1)
   3404             continue;
   3405 
   3406         object_get(id, &obj);
   3407 
   3408         const char *msg_id = NULL;
   3409 
   3410         if (xs_is_dict(obj))
   3411             msg_id = xs_dict_get(obj, "id");
   3412 
   3413         const char *actor_id = xs_dict_get(noti, "actor");
   3414         xs *actor = NULL;
   3415 
   3416         if (!valid_status(actor_get(actor_id, &actor)))
   3417             continue;
   3418 
   3419         xs *a_name = actor_name(actor, proxy);
   3420         xs *label_sanatized = sanitize(type);
   3421         const char *label = label_sanatized;
   3422 
   3423         if (strcmp(type, "Create") == 0)
   3424             label = L("Mention");
   3425         else
   3426         if (strcmp(type, "Update") == 0 && strcmp(utype, "Question") == 0)
   3427             label = L("Finished poll");
   3428         else
   3429         if (strcmp(type, "Undo") == 0 && strcmp(utype, "Follow") == 0)
   3430             label = L("Unfollow");
   3431         else
   3432         if (strcmp(type, "EmojiReact") == 0 || strcmp(type, "Like") == 0) {
   3433             const char *content = xs_dict_get_path(noti, "msg.content");
   3434 
   3435             if (xs_type(content) == XSTYPE_STRING) {
   3436                 xs *emoji = replace_shortnames(xs_dup(content), xs_dict_get_path(noti, "msg.tag"), 1, proxy);
   3437                 wrk = xs_fmt("%s (%s&#xFE0F;)", type, emoji);
   3438                 label = wrk;
   3439             }
   3440         }
   3441         else
   3442         if (strcmp(type, "Follow") == 0 && pending_check(user, actor_id))
   3443             label = L("Follow Request");
   3444 
   3445         xs *s_date = xs_crop_i(xs_dup(date), 0, 10);
   3446 
   3447         xs_html *this_html_label = xs_html_container(
   3448                 xs_html_tag("b",
   3449                     xs_html_raw(label),
   3450                     xs_html_text(" by "),
   3451                     xs_html_tag("a",
   3452                         xs_html_attr("href", actor_id),
   3453                         xs_html_raw(a_name))), /* a_name is already sanitized */
   3454                 xs_html_text(" "),
   3455                 xs_html_tag("time",
   3456                     xs_html_attr("class", "dt-published snac-pubdate"),
   3457                     xs_html_attr("title", date),
   3458                     xs_html_text(s_date)));
   3459 
   3460         xs_html *html_label = NULL;
   3461 
   3462         if (xs_is_string(msg_id)) {
   3463             const xs_val *prev_label = xs_dict_get(admiration_labels, msg_id);
   3464 
   3465             if (xs_type(prev_label) == XSTYPE_DATA) {
   3466                 /* there is a previous list of admiration labels! */
   3467                 xs_data_get(&html_label, prev_label);
   3468 
   3469                 xs_html_add(html_label,
   3470                     xs_html_sctag("br", NULL),
   3471                     this_html_label);
   3472 
   3473                 continue;
   3474             }
   3475         }
   3476 
   3477         xs_html *entry = NULL;
   3478 
   3479         html_label = xs_html_tag("p",
   3480             this_html_label);
   3481 
   3482         /* store in the admiration labels dict */
   3483         xs *pl = xs_data_new(&html_label, sizeof(html_label));
   3484 
   3485         if (xs_is_string(msg_id))
   3486             admiration_labels = xs_dict_set(admiration_labels, msg_id, pl);
   3487 
   3488         entry = xs_html_tag("div",
   3489             xs_html_attr("class", "snac-post-with-desc"),
   3490             html_label);
   3491 
   3492         if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0 || strcmp(type, "Block") == 0) {
   3493             xs_html_add(entry,
   3494                 xs_html_tag("div",
   3495                     xs_html_attr("class", "snac-post"),
   3496                     html_actor_icon(user, actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
   3497         }
   3498         else
   3499         if (strcmp(type, "Move") == 0) {
   3500             const xs_dict *o_msg = xs_dict_get(noti, "msg");
   3501             const char *target;
   3502 
   3503             if (xs_type(o_msg) == XSTYPE_DICT && (target = xs_dict_get(o_msg, "target"))) {
   3504                 xs *old_actor = NULL;
   3505 
   3506                 if (valid_status(actor_get(target, &old_actor))) {
   3507                     xs_html_add(entry,
   3508                         xs_html_tag("div",
   3509                             xs_html_attr("class", "snac-post"),
   3510                             html_actor_icon(user, old_actor, NULL, NULL, NULL, 0, 0, proxy, NULL, NULL)));
   3511                 }
   3512             }
   3513         }
   3514         else
   3515         if (obj != NULL) {
   3516             const char *md5 = xs_md5(id);
   3517             xs *ctxt = xs_fmt("%s/admin/p/%s#%s_entry", user->actor, md5, md5);
   3518 
   3519             xs_html *h = html_entry(user, obj, 0, 0, md5, 1);
   3520 
   3521             if (h != NULL) {
   3522                 xs_html_add(entry,
   3523                     xs_html_tag("p",
   3524                         xs_html_tag("a",
   3525                             xs_html_attr("href", ctxt),
   3526                             xs_html_text(L("Context")))),
   3527                     h);
   3528             }
   3529         }
   3530 
   3531         if (strcmp(v, n_time) > 0) {
   3532             /* unseen notification */
   3533             if (noti_new == NULL) {
   3534                 noti_new = xs_html_tag("div",
   3535                     xs_html_tag("h2",
   3536                         xs_html_attr("class", "snac-header"),
   3537                         xs_html_text(L("New"))));
   3538 
   3539                 xs_html_add(posts,
   3540                     noti_new);
   3541             }
   3542 
   3543             xs_html_add(noti_new,
   3544                 entry);
   3545         }
   3546         else {
   3547             /* already seen notification */
   3548             if (noti_seen == NULL) {
   3549                 noti_seen = xs_html_tag("div",
   3550                     xs_html_tag("h2",
   3551                         xs_html_attr("class", "snac-header"),
   3552                         xs_html_text(L("Already seen"))));
   3553 
   3554                 xs_html_add(posts,
   3555                     noti_seen);
   3556             }
   3557 
   3558             xs_html_add(noti_seen,
   3559                 entry);
   3560         }
   3561     }
   3562 
   3563     if (noti_new == NULL && noti_seen == NULL)
   3564         xs_html_add(body,
   3565             xs_html_tag("h2",
   3566                 xs_html_attr("class", "snac-header"),
   3567                 xs_html_text(L("None"))));
   3568 
   3569     /* add the navigation footer */
   3570     xs *next_p = notify_list(user, skip + show, 1);
   3571     if (xs_list_len(next_p)) {
   3572         xs *url = xs_fmt("%s/notifications?skip=%d&show=%d",
   3573             user->actor, skip + show, show);
   3574 
   3575         xs_html_add(body,
   3576             xs_html_tag("p",
   3577                 xs_html_tag("a",
   3578                     xs_html_attr("href", url),
   3579                     xs_html_text(L("More...")))));
   3580     }
   3581 
   3582     xs_set_free(&rep);
   3583 
   3584     xs_html_add(body,
   3585         html_footer(user));
   3586 
   3587     /* set the check time to now */
   3588     xs *dummy = notify_check_time(user, 1);
   3589     dummy = xs_free(dummy);
   3590 
   3591     timeline_touch(user);
   3592 
   3593     return xs_html_render_s(html, "<!DOCTYPE html>\n");
   3594 }
   3595 
   3596 
   3597 void set_user_lang(snac *user)
   3598 /* sets the language dict according to user configuration */
   3599 {
   3600     user->lang = NULL;
   3601     const char *lang = xs_dict_get(user->config, "lang");
   3602 
   3603     if (xs_is_string(lang))
   3604         user->lang = xs_dict_get(srv_langs, lang);
   3605 }
   3606 
   3607 
   3608 int html_get_handler(const xs_dict *req, const char *q_path,
   3609                      char **body, int *b_size, char **ctype,
   3610                      xs_str **etag, xs_str **last_modified,
   3611                      xs_dict **headers, int *mmapped)
   3612 {
   3613     const char *accept = xs_dict_get(req, "accept");
   3614     int status = HTTP_STATUS_NOT_FOUND;
   3615     const snac *user = NULL;
   3616     snac snac;
   3617     xs *uid = NULL;
   3618     const char *p_path;
   3619     int cache = 1;
   3620     int save = 1;
   3621     int proxy = 0;
   3622     const char *v;
   3623 
   3624     const xs_dict *q_vars = xs_dict_get(req, "q_vars");
   3625 
   3626     *mmapped = 0;
   3627 
   3628     xs *l = xs_split_n(q_path, "/", 2);
   3629     v = xs_list_get(l, 1);
   3630 
   3631     if (xs_is_null(v)) {
   3632         srv_log(xs_fmt("html_get_handler bad query '%s'", q_path));
   3633         return HTTP_STATUS_NOT_FOUND;
   3634     }
   3635 
   3636     if (strcmp(v, "share-bridge") == 0) {
   3637         /* temporary redirect for a post */
   3638         const char *login = xs_dict_get(q_vars, "login");
   3639         const char *content = xs_dict_get(q_vars, "content");
   3640 
   3641         if (xs_type(login) == XSTYPE_STRING && xs_type(content) == XSTYPE_STRING) {
   3642             xs *b64 = xs_base64_enc(content, strlen(content));
   3643 
   3644             srv_log(xs_fmt("share-bridge for user '%s'", login));
   3645 
   3646             *body = xs_fmt("%s/%s/share?content=%s", srv_baseurl, login, b64);
   3647             return HTTP_STATUS_SEE_OTHER;
   3648         }
   3649         else
   3650             return HTTP_STATUS_NOT_FOUND;
   3651     }
   3652     else
   3653     if (strcmp(v, "auth-int-bridge") == 0) {
   3654         const char *login  = xs_dict_get(q_vars, "login");
   3655         const char *id     = xs_dict_get(q_vars, "id");
   3656         const char *action = xs_dict_get(q_vars, "action");
   3657 
   3658         if (xs_is_string(login) && xs_is_string(id) && xs_is_string(action)) {
   3659             *body = xs_fmt("%s/%s/authorize_interaction?action=%s&id=%s",
   3660                 srv_baseurl, login, action, id);
   3661 
   3662             return HTTP_STATUS_SEE_OTHER;
   3663         }
   3664         else
   3665             return HTTP_STATUS_NOT_FOUND;
   3666     }
   3667 
   3668     uid = xs_dup(v);
   3669 
   3670     /* rss extension? */
   3671     if (xs_endswith(uid, ".rss")) {
   3672         uid = xs_crop_i(uid, 0, -4);
   3673         p_path = ".rss";
   3674     }
   3675     else
   3676         p_path = xs_list_get(l, 2);
   3677 
   3678     if (!uid || !user_open(&snac, uid)) {
   3679         /* invalid user */
   3680         srv_debug(1, xs_fmt("html_get_handler bad user %s", uid));
   3681         return HTTP_STATUS_NOT_FOUND;
   3682     }
   3683 
   3684     user = &snac; /* for L() */
   3685     set_user_lang(&snac);
   3686 
   3687     if (xs_is_true(xs_dict_get(srv_config, "proxy_media")))
   3688         proxy = 1;
   3689 
   3690     /* return the RSS if requested by Accept header */
   3691     if (accept != NULL) {
   3692         if (xs_str_in(accept, "text/xml") != -1 ||
   3693             xs_str_in(accept, "application/rss+xml") != -1)
   3694             p_path = ".rss";
   3695     }
   3696 
   3697     /* check if server config variable 'disable_cache' is set */
   3698     if ((v = xs_dict_get(srv_config, "disable_cache")) && xs_type(v) == XSTYPE_TRUE)
   3699         cache = 0;
   3700 
   3701     int skip = 0;
   3702     int def_show = xs_number_get(xs_dict_get_def(srv_config, "def_timeline_entries",
   3703                                  xs_dict_get_def(srv_config, "max_timeline_entries", "50")));
   3704     int show = def_show;
   3705 
   3706     if ((v = xs_dict_get(q_vars, "skip")) != NULL)
   3707         skip = atoi(v), cache = 0, save = 0;
   3708     if ((v = xs_dict_get(q_vars, "show")) != NULL)
   3709         show = atoi(v), cache = 0, save = 0;
   3710     if ((v = xs_dict_get(q_vars, "da")) != NULL) {
   3711         /* user dismissed an announcement */
   3712         if (login(&snac, req)) {
   3713             double ts = atof(v);
   3714             xs *timestamp = xs_number_new(ts);
   3715             srv_log(xs_fmt("user dismissed announcements until %d", ts));
   3716             snac.config = xs_dict_set(snac.config, "last_announcement", timestamp);
   3717             user_persist(&snac, 0);
   3718         }
   3719     }
   3720 
   3721     /* get a possible error message */
   3722     const char *error = xs_dict_get(q_vars, "error");
   3723     if (error != NULL)
   3724         cache = 0;
   3725 
   3726     /* a show of 0 has no sense */
   3727     if (show == 0)
   3728         show = def_show;
   3729 
   3730     if (p_path == NULL) { /** public timeline **/
   3731         xs *h = xs_str_localtime(0, "%Y-%m.html");
   3732 
   3733         if (xs_type(xs_dict_get(snac.config, "private")) == XSTYPE_TRUE) {
   3734             /** empty public timeline for private users **/
   3735             *body = html_timeline(&snac, NULL, 1, 0, 0, 0, NULL, "", 1, error);
   3736             *b_size = strlen(*body);
   3737             status  = HTTP_STATUS_OK;
   3738         }
   3739         else
   3740         if (cache && history_mtime(&snac, h) > timeline_mtime(&snac)) {
   3741             snac_debug(&snac, 1, xs_fmt("serving cached local timeline"));
   3742 
   3743             status = history_get(&snac, h, body, b_size,
   3744                         xs_dict_get(req, "if-none-match"), etag, mmapped);
   3745         }
   3746         else {
   3747             xs *list = NULL;
   3748             int more = 0;
   3749 
   3750             if (xs_is_true(xs_dict_get(srv_config, "strict_public_timelines")))
   3751                 list = timeline_simple_list(&snac, "public", skip, show, &more);
   3752             else
   3753                 list = timeline_list(&snac, "public", skip, show, &more);
   3754 
   3755             xs *pins = pinned_list(&snac);
   3756             if (xs_is_list(list))
   3757                 pins = xs_list_cat(pins, list);
   3758 
   3759             *body = html_timeline(&snac, pins, 1, skip, show, more, NULL, "", 1, error);
   3760 
   3761             *b_size = strlen(*body);
   3762             status  = HTTP_STATUS_OK;
   3763 
   3764             if (save)
   3765                 history_add(&snac, h, *body, *b_size, etag);
   3766         }
   3767     }
   3768     else
   3769     if (strcmp(p_path, "admin") == 0) { /** private timeline **/
   3770         if (!login(&snac, req)) {
   3771             *body  = xs_dup(uid);
   3772             status = HTTP_STATUS_UNAUTHORIZED;
   3773         }
   3774         else {
   3775             const char *q = xs_dict_get(q_vars, "q");
   3776             xs *url_acct = NULL;
   3777 
   3778             /* searching for an URL? */
   3779             if (q && xs_match(q, "https://*|http://*")) {
   3780                 /* may by an actor; try a webfinger */
   3781                 xs *actor_obj = NULL;
   3782 
   3783                 if (valid_status(webfinger_request(q, &actor_obj, &url_acct)) && xs_is_string(url_acct)) {
   3784                     /* it's an actor; do the dirty trick of changing q to the account name */
   3785                     q = url_acct;
   3786                 }
   3787                 else {
   3788                     /* bring it to the user's timeline */
   3789                     xs *object = NULL;
   3790                     int status;
   3791 
   3792                     status = activitypub_request(&snac, q, &object);
   3793                     snac_debug(&snac, 1, xs_fmt("Request searched URL %s %d", q, status));
   3794 
   3795                     if (valid_status(status)) {
   3796                         /* got it; also request the actor */
   3797                         const char *attr_to = get_atto(object);
   3798 
   3799                         if (!xs_is_null(attr_to)) {
   3800                             status = actor_request(&snac, attr_to, &actor_obj);
   3801 
   3802                             if (valid_status(status)) {
   3803                                 /* reset the query string to be the real id */
   3804                                 url_acct = xs_dup(xs_dict_get(object, "id"));
   3805                                 q = url_acct;
   3806 
   3807                                 /* add the post to the timeline */
   3808                                 if (!timeline_here(&snac, q))
   3809                                     timeline_add(&snac, q, object);
   3810                             }
   3811                         }
   3812                         else {
   3813                             /* retry webfinger, this time with the 'official' id */
   3814                             const char *id = xs_dict_get(object, "id");
   3815 
   3816                             if (xs_is_string(id) && valid_status(webfinger_request(id, &actor_obj, &url_acct)) &&
   3817                                 xs_is_string(url_acct))
   3818                                 q = url_acct;
   3819                         }
   3820                     }
   3821                 }
   3822 
   3823                 /* fall through */
   3824             }
   3825 
   3826             if (q && *q) {
   3827                 if (xs_regex_match(q, "^@?[a-zA-Z0-9._]+@[a-zA-Z0-9-]+\\.")) {
   3828                     /** search account **/
   3829                     xs *actor = NULL;
   3830                     xs *acct = NULL;
   3831                     xs *l = xs_list_new();
   3832                     xs_html *page = NULL;
   3833 
   3834                     if (valid_status(webfinger_request(q, &actor, &acct))) {
   3835                         xs *actor_obj = NULL;
   3836 
   3837                         if (valid_status(actor_request(&snac, actor, &actor_obj))) {
   3838                             actor_add(actor, actor_obj);
   3839 
   3840                             /* create a people list with only one element */
   3841                             l = xs_list_append(xs_list_new(), actor);
   3842 
   3843                             xs *title = xs_fmt(L("Search results for account %s"), q);
   3844 
   3845                             page = html_people_list(&snac, l, title, "wf", NULL);
   3846                         }
   3847                     }
   3848 
   3849                     if (page == NULL) {
   3850                         xs *title = xs_fmt(L("Account %s not found"), q);
   3851 
   3852                         page = xs_html_tag("div",
   3853                             xs_html_tag("h2",
   3854                                 xs_html_attr("class", "snac-header"),
   3855                                 xs_html_text(title)));
   3856                     }
   3857 
   3858                     xs_html *html = xs_html_tag("html",
   3859                         html_user_head(&snac, NULL, NULL),
   3860                         xs_html_add(html_user_body(&snac, 0),
   3861                         page,
   3862                         html_footer(user)));
   3863 
   3864                     *body = xs_html_render_s(html, "<!DOCTYPE html>\n");
   3865                     *b_size = strlen(*body);
   3866                     status = HTTP_STATUS_OK;
   3867                 }
   3868                 else
   3869                 if (*q == '#') {
   3870                     /** search by tag **/
   3871                     xs *tl = tag_search(q, skip, show + 1);
   3872                     int more = 0;
   3873                     if (xs_list_len(tl) >= show + 1) {
   3874                         /* drop the last one */
   3875                         tl = xs_list_del(tl, -1);
   3876                         more = 1;
   3877                     }
   3878 
   3879                     xs *page = xs_fmt("/admin?q=%%23%s", q + 1);
   3880                     xs *title = xs_fmt(xs_list_len(tl) ?
   3881                         L("Search results for tag %s") : L("Nothing found for tag %s"), q);
   3882 
   3883                     *body = html_timeline(&snac, tl, 0, skip, show, more, title, page, 0, error);
   3884                     *b_size = strlen(*body);
   3885                     status  = HTTP_STATUS_OK;
   3886                 }
   3887                 else {
   3888                     /** search by content **/
   3889                     int to = 0;
   3890                     int msecs = atoi(xs_dict_get_def(q_vars, "msecs", "0"));
   3891                     xs *tl = content_search(&snac, q, 1, skip, show, msecs, &to);
   3892                     xs *title = NULL;
   3893                     xs *page = xs_fmt("/admin?q=%s&msecs=%d", q, msecs + 10);
   3894                     int tl_len = xs_list_len(tl);
   3895 
   3896                     if (to)
   3897                         title = xs_fmt(L("Search results for '%s' (may be more)"), q);
   3898                     else
   3899                     if (tl_len)
   3900                         title = xs_fmt(L("Search results for '%s'"), q);
   3901                     else
   3902                     if (skip)
   3903                         title = xs_fmt(L("No more matches for '%s'"), q);
   3904                     else
   3905                         title = xs_fmt(L("Nothing found for '%s'"), q);
   3906 
   3907                     *body   = html_timeline(&snac, tl, 0, skip, tl_len, to || tl_len == show,
   3908                                             title, page, 0, error);
   3909                     *b_size = strlen(*body);
   3910                     status  = HTTP_STATUS_OK;
   3911                 }
   3912             }
   3913             else {
   3914                 /** the private timeline **/
   3915                 double t = history_mtime(&snac, "timeline.html_");
   3916 
   3917                 /* if enabled by admin, return a cached page if its timestamp is:
   3918                    a) newer than the timeline timestamp
   3919                    b) newer than the start time of the server
   3920                 */
   3921                 if (cache && t > timeline_mtime(&snac) && t > p_state->srv_start_time) {
   3922                     snac_debug(&snac, 1, xs_fmt("serving cached timeline"));
   3923 
   3924                     status = history_get(&snac, "timeline.html_", body, b_size,
   3925                                 xs_dict_get(req, "if-none-match"), etag, mmapped);
   3926                 }
   3927                 else {
   3928                     int more = 0;
   3929 
   3930                     snac_debug(&snac, 1, xs_fmt("building timeline"));
   3931 
   3932                     xs *list = timeline_list(&snac, "private", skip, show, &more);
   3933 
   3934                     *body = html_timeline(&snac, list, 0, skip, show,
   3935                             more, NULL, "/admin", 1, error);
   3936 
   3937                     *b_size = strlen(*body);
   3938                     status  = HTTP_STATUS_OK;
   3939 
   3940                     if (save)
   3941                         history_add(&snac, "timeline.html_", *body, *b_size, etag);
   3942 
   3943                     timeline_add_mark(&snac);
   3944                 }
   3945             }
   3946         }
   3947     }
   3948     else
   3949     if (xs_startswith(p_path, "admin/p/")) { /** unique post by md5 **/
   3950         if (!login(&snac, req)) {
   3951             *body  = xs_dup(uid);
   3952             status = HTTP_STATUS_UNAUTHORIZED;
   3953         }
   3954         else {
   3955             xs *l = xs_split(p_path, "/");
   3956             const char *md5 = xs_list_get(l, -1);
   3957 
   3958             if (md5 && *md5 && timeline_here_by_md5(&snac, md5)) {
   3959                 xs *list0 = xs_list_append(xs_list_new(), md5);
   3960                 xs *list  = timeline_top_level(&snac, list0);
   3961 
   3962                 *body   = html_timeline(&snac, list, 0, 0, 0, 0, NULL, "/admin", 1, error);
   3963                 *b_size = strlen(*body);
   3964                 status  = HTTP_STATUS_OK;
   3965             }
   3966         }
   3967     }
   3968     else
   3969     if (strcmp(p_path, "people") == 0) { /** the list of people **/
   3970         if (!login(&snac, req)) {
   3971             *body  = xs_dup(uid);
   3972             status = HTTP_STATUS_UNAUTHORIZED;
   3973         }
   3974         else {
   3975             *body   = html_people(&snac);
   3976             *b_size = strlen(*body);
   3977             status  = HTTP_STATUS_OK;
   3978         }
   3979     }
   3980     else
   3981     if (strcmp(p_path, "notifications") == 0) { /** the list of notifications **/
   3982         if (!login(&snac, req)) {
   3983             *body  = xs_dup(uid);
   3984             status = HTTP_STATUS_UNAUTHORIZED;
   3985         }
   3986         else {
   3987             *body   = html_notifications(&snac, skip, show);
   3988             *b_size = strlen(*body);
   3989             status  = HTTP_STATUS_OK;
   3990         }
   3991     }
   3992     else
   3993     if (strcmp(p_path, "instance") == 0) { /** instance timeline **/
   3994         if (!login(&snac, req)) {
   3995             *body  = xs_dup(uid);
   3996             status = HTTP_STATUS_UNAUTHORIZED;
   3997         }
   3998         else {
   3999             xs *list = timeline_instance_list(skip, show);
   4000             xs *next = timeline_instance_list(skip + show, 1);
   4001 
   4002             *body = html_timeline(&snac, list, 0, skip, show,
   4003                 xs_list_len(next), L("Showing instance timeline"), "/instance", 0, error);
   4004             *b_size = strlen(*body);
   4005             status  = HTTP_STATUS_OK;
   4006         }
   4007     }
   4008     else
   4009     if (strcmp(p_path, "pinned") == 0) { /** list of pinned posts **/
   4010         if (!login(&snac, req)) {
   4011             *body  = xs_dup(uid);
   4012             status = HTTP_STATUS_UNAUTHORIZED;
   4013         }
   4014         else {
   4015             xs *list = pinned_list(&snac);
   4016 
   4017             *body = html_timeline(&snac, list, 0, skip, show,
   4018                 0, L("Pinned posts"), "", 0, error);
   4019             *b_size = strlen(*body);
   4020             status  = HTTP_STATUS_OK;
   4021         }
   4022     }
   4023     else
   4024     if (strcmp(p_path, "bookmarks") == 0) { /** list of bookmarked posts **/
   4025         if (!login(&snac, req)) {
   4026             *body  = xs_dup(uid);
   4027             status = HTTP_STATUS_UNAUTHORIZED;
   4028         }
   4029         else {
   4030             xs *list = bookmark_list(&snac);
   4031 
   4032             *body = html_timeline(&snac, list, 0, skip, show,
   4033                 0, L("Bookmarked posts"), "", 0, error);
   4034             *b_size = strlen(*body);
   4035             status  = HTTP_STATUS_OK;
   4036         }
   4037     }
   4038     else
   4039     if (strcmp(p_path, "drafts") == 0) { /** list of drafts **/
   4040         if (!login(&snac, req)) {
   4041             *body  = xs_dup(uid);
   4042             status = HTTP_STATUS_UNAUTHORIZED;
   4043         }
   4044         else {
   4045             xs *list = draft_list(&snac);
   4046 
   4047             *body = html_timeline(&snac, list, 0, skip, show,
   4048                 0, L("Post drafts"), "", 0, error);
   4049             *b_size = strlen(*body);
   4050             status  = HTTP_STATUS_OK;
   4051         }
   4052     }
   4053     else
   4054     if (strcmp(p_path, "sched") == 0) { /** list of scheduled posts **/
   4055         if (!login(&snac, req)) {
   4056             *body  = xs_dup(uid);
   4057             status = HTTP_STATUS_UNAUTHORIZED;
   4058         }
   4059         else {
   4060             xs *list = scheduled_list(&snac);
   4061 
   4062             *body = html_timeline(&snac, list, 0, skip, show,
   4063                 0, L("Scheduled posts"), "", 0, error);
   4064             *b_size = strlen(*body);
   4065             status  = HTTP_STATUS_OK;
   4066         }
   4067     }
   4068     else
   4069     if (xs_startswith(p_path, "list/")) { /** list timelines **/
   4070         if (!login(&snac, req)) {
   4071             *body  = xs_dup(uid);
   4072             status = HTTP_STATUS_UNAUTHORIZED;
   4073         }
   4074         else {
   4075             xs *l = xs_split(p_path, "/");
   4076             const char *lid = xs_list_get(l, -1);
   4077 
   4078             xs *list = list_timeline(&snac, lid, skip, show);
   4079             xs *next = list_timeline(&snac, lid, skip + show, 1);
   4080 
   4081             if (list != NULL) {
   4082                 xs *ttl = timeline_top_level(&snac, list);
   4083 
   4084                 xs *base = xs_fmt("/list/%s", lid);
   4085                 xs *name = list_maint(&snac, lid, 3);
   4086                 xs *title = xs_fmt(L("Showing timeline for list '%s'"), name);
   4087 
   4088                 *body = html_timeline(&snac, ttl, 0, skip, show,
   4089                     xs_list_len(next), title, base, 1, error);
   4090                 *b_size = strlen(*body);
   4091                 status  = HTTP_STATUS_OK;
   4092             }
   4093         }
   4094     }
   4095     else
   4096     if (xs_startswith(p_path, "p/")) { /** a timeline with just one entry **/
   4097         if (xs_type(xs_dict_get(snac.config, "private")) == XSTYPE_TRUE)
   4098             return HTTP_STATUS_FORBIDDEN;
   4099 
   4100         xs *id  = xs_fmt("%s/%s", snac.actor, p_path);
   4101         xs *msg = NULL;
   4102 
   4103         if (valid_status(object_get(id, &msg))) {
   4104             const char *md5 = xs_md5(id);
   4105             xs *list = xs_list_new();
   4106 
   4107             list = xs_list_append(list, md5);
   4108 
   4109             *body   = html_timeline(&snac, list, 1, 0, 0, 0, NULL, "", 1, error);
   4110             *b_size = strlen(*body);
   4111             status  = HTTP_STATUS_OK;
   4112         }
   4113     }
   4114     else
   4115     if (xs_startswith(p_path, "s/")) { /** a static file **/
   4116         xs *l    = xs_split(p_path, "/");
   4117         const char *id = xs_list_get(l, 1);
   4118         int sz;
   4119 
   4120         if (id && *id) {
   4121             int start;
   4122             int end;
   4123 
   4124             if (parse_range(req, &start, &end)) {
   4125                 status = static_get_partial(&snac, id, body, &sz,
   4126                         xs_dict_get(req, "if-none-match"), etag,
   4127                         start, &end);
   4128                 if (status == HTTP_STATUS_PARTIAL_CONTENT) {
   4129                     xs *part = NULL;
   4130                     if (sz != XS_ALL)
   4131                        part = xs_fmt("bytes %d-%d/%d", start, end, sz);
   4132                     else
   4133                        part = xs_fmt("bytes %d-%d/*", start, end);
   4134                     *headers = xs_dict_append(*headers, "Content-Range", part);
   4135                     *b_size = end - start + 1;
   4136                     *ctype  = (char *)xs_mime_by_ext(id);
   4137                 }
   4138             } else {
   4139                 status = static_get(&snac, id, body, &sz,
   4140                         xs_dict_get(req, "if-none-match"), etag, mmapped);
   4141             }
   4142 
   4143             if (valid_status(status) && status != HTTP_STATUS_PARTIAL_CONTENT) {
   4144                 *b_size = sz;
   4145                 *ctype  = (char *)xs_mime_by_ext(id);
   4146             }
   4147         }
   4148     }
   4149     else
   4150     if (xs_startswith(p_path, "h/")) { /** an entry from the history **/
   4151         if (xs_type(xs_dict_get(snac.config, "private")) == XSTYPE_TRUE)
   4152             return HTTP_STATUS_FORBIDDEN;
   4153 
   4154         if (xs_type(xs_dict_get(srv_config, "disable_history")) == XSTYPE_TRUE)
   4155             return HTTP_STATUS_FORBIDDEN;
   4156 
   4157         xs *l = xs_split(p_path, "/");
   4158         const char *id = xs_list_get(l, 1);
   4159 
   4160         if (id && *id) {
   4161             if (xs_endswith(id, "timeline.html_")) {
   4162                 /* Don't let them in */
   4163                 *b_size = 0;
   4164                 status = HTTP_STATUS_NOT_FOUND;
   4165             }
   4166             else
   4167                 status = history_get(&snac, id, body, b_size,
   4168                             xs_dict_get(req, "if-none-match"), etag, mmapped);
   4169         }
   4170     }
   4171     else
   4172     if (strcmp(p_path, ".rss") == 0) { /** public timeline in RSS format **/
   4173         if (xs_type(xs_dict_get(snac.config, "private")) == XSTYPE_TRUE)
   4174             return HTTP_STATUS_FORBIDDEN;
   4175 
   4176         int cnt = xs_number_get(xs_dict_get_def(srv_config, "max_public_entries", "20"));
   4177 
   4178         xs *elems = timeline_simple_list(&snac, "public", 0, cnt, NULL);
   4179         xs *bio   = xs_dup(xs_dict_get(snac.config, "bio"));
   4180 
   4181         xs *rss_title = xs_fmt("%s (@%s@%s)",
   4182             xs_dict_get(snac.config, "name"),
   4183             snac.uid,
   4184             xs_dict_get(srv_config, "host"));
   4185         xs *rss_link = xs_fmt("%s.rss", snac.actor);
   4186 
   4187         *body   = rss_from_timeline(&snac, elems, rss_title, rss_link, bio);
   4188         *b_size = strlen(*body);
   4189         *ctype  = "application/rss+xml; charset=utf-8";
   4190         status  = HTTP_STATUS_OK;
   4191 
   4192         snac_debug(&snac, 1, xs_fmt("serving RSS"));
   4193     }
   4194     else
   4195     if (proxy && (xs_startswith(p_path, "x/") || xs_startswith(p_path, "y/"))) { /** remote media by proxy **/
   4196         xs *proxy_prefix = NULL;
   4197 
   4198         if (xs_startswith(p_path, "x/")) {
   4199             /* proxy usage authorized by http basic auth */
   4200             if (login(&snac, req))
   4201                 proxy_prefix = xs_str_new("x/");
   4202             else {
   4203                 *body  = xs_dup(uid);
   4204                 status = HTTP_STATUS_UNAUTHORIZED;
   4205             }
   4206         }
   4207         else {
   4208             /* proxy usage authorized by proxy_token */
   4209             const char *tk = xs_md5(srv_proxy_token_seed, ":", snac.actor);
   4210             xs *p = xs_fmt("y/%s/", tk);
   4211 
   4212             if (xs_startswith(p_path, p))
   4213                 proxy_prefix = xs_dup(p);
   4214         }
   4215 
   4216         if (proxy_prefix) {
   4217             /* pick the raw path (including optional ? arguments) */
   4218             const char *raw_path = xs_dict_get(req, "raw_path");
   4219 
   4220             /* skip to where the proxy/ string starts */
   4221             raw_path += xs_str_in(raw_path, proxy_prefix);
   4222 
   4223             xs *url = xs_replace_n(raw_path, proxy_prefix, "https:/" "/", 1);
   4224             xs *hdrs = xs_dict_new();
   4225 
   4226             hdrs = xs_dict_append(hdrs, "user-agent", USER_AGENT);
   4227 
   4228             const char *ims = xs_dict_get(req, "if-modified-since");
   4229             const char *inm = xs_dict_get(req, "if-none-match");
   4230 
   4231             if (ims) hdrs = xs_dict_append(hdrs, "if-modified-since", ims);
   4232             if (inm) hdrs = xs_dict_append(hdrs, "if-none-match", inm);
   4233 
   4234             xs *rsp = xs_http_request("GET", url, hdrs,
   4235                         NULL, 0, &status, body, b_size, 0);
   4236 
   4237             if (valid_status(status)) {
   4238                 const char *ct = xs_or(xs_dict_get(rsp, "content-type"), "");
   4239                 const char *lm = xs_dict_get(rsp, "last-modified");
   4240                 const char *et = xs_dict_get(rsp, "etag");
   4241 
   4242                 if (lm) *last_modified = xs_dup(lm);
   4243                 if (et) *etag = xs_dup(et);
   4244 
   4245                 /* find the content-type in the static mime types,
   4246                    and return that value instead of ct, which will
   4247                    be destroyed when out of scope */
   4248                 for (int n = 0; xs_mime_types[n]; n += 2) {
   4249                     if (strcmp(ct, xs_mime_types[n + 1]) == 0) {
   4250                         *ctype = (char *)xs_mime_types[n + 1];
   4251                         break;
   4252                     }
   4253                 }
   4254             }
   4255 
   4256             snac_debug(&snac, 1, xs_fmt("Proxy for %s %d", url, status));
   4257         }
   4258     }
   4259     else
   4260     if (strcmp(p_path, "share") == 0) { /** direct post **/
   4261         if (!login(&snac, req)) {
   4262             *body  = xs_dup(uid);
   4263             status = HTTP_STATUS_UNAUTHORIZED;
   4264         }
   4265         else {
   4266             const char *b64 = xs_dict_get(q_vars, "content");
   4267             int sz;
   4268             xs *content = xs_base64_dec(b64, &sz);
   4269             xs *msg = msg_note(&snac, content, NULL, NULL, NULL, 0, NULL, NULL);
   4270             xs *c_msg = msg_create(&snac, msg);
   4271 
   4272             timeline_add(&snac, xs_dict_get(msg, "id"), msg);
   4273 
   4274             enqueue_message(&snac, c_msg);
   4275 
   4276             snac_debug(&snac, 1, xs_fmt("web action 'share' received"));
   4277 
   4278             *body   = xs_fmt("%s/admin", snac.actor);
   4279             *b_size = strlen(*body);
   4280             status  = HTTP_STATUS_SEE_OTHER;
   4281         }
   4282     }
   4283     else
   4284     if (strcmp(p_path, "authorize_interaction") == 0) { /** follow, like or boost from Mastodon **/
   4285         if (!login(&snac, req)) {
   4286             *body  = xs_dup(uid);
   4287             status = HTTP_STATUS_UNAUTHORIZED;
   4288         }
   4289         else {
   4290             status = HTTP_STATUS_NOT_FOUND;
   4291 
   4292             const char *id     = xs_dict_get(q_vars, "id");
   4293             const char *action = xs_dict_get(q_vars, "action");
   4294 
   4295             if (xs_is_string(id) && xs_is_string(action)) {
   4296                 if (strcmp(action, "Follow") == 0) {
   4297                     xs *msg = msg_follow(&snac, id);
   4298 
   4299                     if (msg != NULL) {
   4300                         const char *actor = xs_dict_get(msg, "object");
   4301 
   4302                         following_add(&snac, actor, msg);
   4303 
   4304                         enqueue_output_by_actor(&snac, msg, actor, 0);
   4305 
   4306                         status = HTTP_STATUS_SEE_OTHER;
   4307                     }
   4308                 }
   4309                 else
   4310                 if (xs_match(action, "Like|Boost|Announce")) {
   4311                     /* bring the post */
   4312                     xs *msg = msg_admiration(&snac, id, *action == 'L' ? "Like" : "Announce");
   4313 
   4314                     if (msg != NULL) {
   4315                         enqueue_message(&snac, msg);
   4316                         timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, *action == 'L' ? 1 : 0);
   4317 
   4318                         status = HTTP_STATUS_SEE_OTHER;
   4319                     }
   4320                 }
   4321             }
   4322 
   4323             if (status == HTTP_STATUS_SEE_OTHER) {
   4324                 *body   = xs_fmt("%s/admin", snac.actor);
   4325                 *b_size = strlen(*body);
   4326             }
   4327         }
   4328     }
   4329     else
   4330         status = HTTP_STATUS_NOT_FOUND;
   4331 
   4332     user_free(&snac);
   4333 
   4334     if (valid_status(status) && *ctype == NULL) {
   4335         *ctype = "text/html; charset=utf-8";
   4336     }
   4337 
   4338     return status;
   4339 }
   4340 
   4341 
   4342 int html_post_handler(const xs_dict *req, const char *q_path,
   4343                       char *payload, int p_size,
   4344                       char **body, int *b_size, char **ctype)
   4345 {
   4346     (void)p_size;
   4347     (void)ctype;
   4348 
   4349     int status = 0;
   4350     const snac *user = NULL;
   4351     snac snac;
   4352     const char *uid;
   4353     const char *p_path;
   4354     const xs_dict *p_vars;
   4355 
   4356     xs *l = xs_split_n(q_path, "/", 2);
   4357 
   4358     uid = xs_list_get(l, 1);
   4359     if (!uid || !user_open(&snac, uid)) {
   4360         /* invalid user */
   4361         srv_debug(1, xs_fmt("html_post_handler bad user %s", uid));
   4362         return HTTP_STATUS_NOT_FOUND;
   4363     }
   4364 
   4365     p_path = xs_list_get(l, 2);
   4366 
   4367     /* all posts must be authenticated */
   4368     if (!login(&snac, req)) {
   4369         user_free(&snac);
   4370         *body  = xs_dup(uid);
   4371         return HTTP_STATUS_UNAUTHORIZED;
   4372     }
   4373 
   4374     user = &snac; /* for L() */
   4375     set_user_lang(&snac);
   4376 
   4377     p_vars = xs_dict_get(req, "p_vars");
   4378 
   4379     if (p_path && strcmp(p_path, "admin/note") == 0) { /** **/
   4380         snac_debug(&snac, 1, xs_fmt("web action '%s' received", p_path));
   4381 
   4382         /* post note */
   4383         const char *content      = xs_dict_get(p_vars, "content");
   4384         const char *in_reply_to  = xs_dict_get(p_vars, "in_reply_to");
   4385         const char *to           = xs_dict_get(p_vars, "to");
   4386         const char *sensitive    = xs_dict_get(p_vars, "sensitive");
   4387         const char *summary      = xs_dict_get(p_vars, "summary");
   4388         const char *edit_id      = xs_dict_get(p_vars, "edit_id");
   4389         const char *post_date    = xs_dict_get_def(p_vars, "post_date", "");
   4390         const char *post_time    = xs_dict_get_def(p_vars, "post_time", "");
   4391         int priv             = !xs_is_null(xs_dict_get(p_vars, "mentioned_only"));
   4392         int store_as_draft   = !xs_is_null(xs_dict_get(p_vars, "is_draft"));
   4393         xs *attach_list      = xs_list_new();
   4394 
   4395         /* iterate the attachments */
   4396         int max_attachments = xs_number_get(xs_dict_get_def(srv_config, "max_attachments", "4"));
   4397 
   4398         for (int att_n = 0; att_n < max_attachments; att_n++) {
   4399             xs *url_lbl = xs_fmt("attach_url_%d", att_n);
   4400             xs *att_lbl = xs_fmt("attach_%d", att_n);
   4401             xs *alt_lbl = xs_fmt("alt_text_%d", att_n);
   4402 
   4403             const char *attach_url     = xs_dict_get(p_vars, url_lbl);
   4404             const xs_list *attach_file = xs_dict_get(p_vars, att_lbl);
   4405             const char *alt_text       = xs_dict_get_def(p_vars, alt_lbl, "");
   4406 
   4407             if (xs_is_string(attach_url) && *attach_url != '\0') {
   4408                 xs *l = xs_list_new();
   4409 
   4410                 l = xs_list_append(l, attach_url);
   4411                 l = xs_list_append(l, alt_text);
   4412 
   4413                 attach_list = xs_list_append(attach_list, l);
   4414             }
   4415             else
   4416             if (xs_is_list(attach_file)) {
   4417                 const char *fn = xs_list_get(attach_file, 0);
   4418 
   4419                 if (xs_is_string(fn) && *fn != '\0') {
   4420                     char rnd[32];
   4421                     xs_rnd_buf(rnd, sizeof(rnd));
   4422 
   4423                     const char *ext = strrchr(fn, '.');
   4424                     const char *hash  = xs_md5_arr(rnd);
   4425                     xs *id    = xs_fmt("post-%s%s", hash, ext ? ext : "");
   4426                     xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
   4427                     int fo    = xs_number_get(xs_list_get(attach_file, 1));
   4428                     int fs    = xs_number_get(xs_list_get(attach_file, 2));
   4429 
   4430                     /* store */
   4431                     static_put(&snac, id, payload + fo, fs);
   4432 
   4433                     xs *l = xs_list_new();
   4434 
   4435                     l = xs_list_append(l, url);
   4436                     l = xs_list_append(l, alt_text);
   4437 
   4438                     attach_list = xs_list_append(attach_list, l);
   4439                 }
   4440             }
   4441         }
   4442 
   4443         if (content != NULL) {
   4444             xs *msg       = NULL;
   4445             xs *c_msg     = NULL;
   4446             xs *content_2 = xs_replace(content, "\r", "");
   4447             xs *poll_opts = NULL;
   4448 
   4449             /* is there a valid set of poll options? */
   4450             const char *v = xs_dict_get(p_vars, "poll_options");
   4451             if (!xs_is_null(v) && *v) {
   4452                 xs *v2 = xs_strip_i(xs_replace(v, "\r", ""));
   4453 
   4454                 poll_opts = xs_split(v2, "\n");
   4455             }
   4456 
   4457             if (!xs_is_null(poll_opts) && xs_list_len(poll_opts)) {
   4458                 /* get the rest of poll configuration */
   4459                 const char *p_multiple = xs_dict_get(p_vars, "poll_multiple");
   4460                 const char *p_end_secs = xs_dict_get(p_vars, "poll_end_secs");
   4461                 int multiple = 0;
   4462 
   4463                 int end_secs = atoi(!xs_is_null(p_end_secs) ? p_end_secs : "60");
   4464 
   4465                 if (!xs_is_null(p_multiple) && strcmp(p_multiple, "on") == 0)
   4466                     multiple = 1;
   4467 
   4468                 msg = msg_question(&snac, content_2, attach_list,
   4469                                    poll_opts, multiple, end_secs);
   4470 
   4471                 enqueue_close_question(&snac, xs_dict_get(msg, "id"), end_secs);
   4472             }
   4473             else
   4474                 msg = msg_note(&snac, content_2, to, in_reply_to, attach_list, priv, NULL, NULL);
   4475 
   4476             if (sensitive != NULL) {
   4477                 msg = xs_dict_set(msg, "sensitive", xs_stock(XSTYPE_TRUE));
   4478                 msg = xs_dict_set(msg, "summary",   xs_is_null(summary) ? "..." : summary);
   4479             }
   4480 
   4481             if (xs_is_string(post_date) && *post_date) {
   4482                 xs *post_pubdate = xs_fmt("%sT%s", post_date,
   4483                     xs_is_string(post_time) && *post_time ? post_time : "00:00:00");
   4484 
   4485                 time_t t = xs_parse_iso_date(post_pubdate, 0);
   4486 
   4487                 if (t != 0) {
   4488                     t -= xs_tz_offset(snac.tz);
   4489 
   4490                     xs *iso_date = xs_str_iso_date(t);
   4491                     msg = xs_dict_set(msg, "published", iso_date);
   4492 
   4493                     snac_debug(&snac, 1, xs_fmt("Published date: [%s]", iso_date));
   4494                 }
   4495                 else
   4496                     snac_log(&snac, xs_fmt("Invalid post date: [%s]", post_pubdate));
   4497             }
   4498 
   4499             /* is the published date from the future? */
   4500             int future_post = 0;
   4501             xs *right_now = xs_str_utctime(0, ISO_DATE_SPEC);
   4502 
   4503             if (strcmp(xs_dict_get(msg, "published"), right_now) > 0)
   4504                 future_post = 1;
   4505 
   4506             if (xs_is_null(edit_id)) {
   4507                 /* new message */
   4508                 const char *id = xs_dict_get(msg, "id");
   4509 
   4510                 if (store_as_draft) {
   4511                     draft_add(&snac, id, msg);
   4512                 }
   4513                 else
   4514                 if (future_post) {
   4515                     schedule_add(&snac, id, msg);
   4516                 }
   4517                 else {
   4518                     c_msg = msg_create(&snac, msg);
   4519                     timeline_add(&snac, id, msg);
   4520                 }
   4521             }
   4522             else {
   4523                 /* an edition of a previous message */
   4524                 xs *p_msg = NULL;
   4525 
   4526                 if (valid_status(object_get(edit_id, &p_msg))) {
   4527                     /* copy relevant fields from previous version */
   4528                     char *fields[] = { "id", "context", "url",
   4529                                        "to", "inReplyTo", NULL };
   4530                     int n;
   4531 
   4532                     for (n = 0; fields[n]; n++) {
   4533                         const char *v = xs_dict_get(p_msg, fields[n]);
   4534                         msg = xs_dict_set(msg, fields[n], v);
   4535                     }
   4536 
   4537                     if (store_as_draft) {
   4538                         draft_add(&snac, edit_id, msg);
   4539                     }
   4540                     else
   4541                     if (is_draft(&snac, edit_id)) {
   4542                         /* message was previously a draft; it's a create activity */
   4543 
   4544                         /* if the date is from the past, overwrite it with right_now */
   4545                         if (strcmp(xs_dict_get(msg, "published"), right_now) < 0) {
   4546                             snac_debug(&snac, 1, xs_fmt("setting draft ancient date to %s", right_now));
   4547                             msg = xs_dict_set(msg, "published", right_now);
   4548                         }
   4549 
   4550                         /* overwrite object */
   4551                         object_add_ow(edit_id, msg);
   4552 
   4553                         if (future_post) {
   4554                             schedule_add(&snac, edit_id, msg);
   4555                         }
   4556                         else {
   4557                             c_msg = msg_create(&snac, msg);
   4558                             timeline_add(&snac, edit_id, msg);
   4559                         }
   4560 
   4561                         draft_del(&snac, edit_id);
   4562                     }
   4563                     else
   4564                     if (is_scheduled(&snac, edit_id)) {
   4565                         /* editing an scheduled post; just update it */
   4566                         schedule_add(&snac, edit_id, msg);
   4567                     }
   4568                     else {
   4569                         /* ignore the (possibly changed) published date */
   4570                         msg = xs_dict_set(msg, "published", xs_dict_get(p_msg, "published"));
   4571 
   4572                         /* set the updated field */
   4573                         xs *updated = xs_str_utctime(0, ISO_DATE_SPEC);
   4574                         msg = xs_dict_set(msg, "updated", updated);
   4575 
   4576                         /* overwrite object, not updating the indexes */
   4577                         object_add_ow(edit_id, msg);
   4578 
   4579                         /* update message */
   4580                         c_msg = msg_update(&snac, msg);
   4581                     }
   4582                 }
   4583                 else
   4584                     snac_log(&snac, xs_fmt("cannot get object '%s' for editing", edit_id));
   4585             }
   4586 
   4587             if (c_msg != NULL) {
   4588                 enqueue_message(&snac, c_msg);
   4589                 enqueue_webmention(msg);
   4590             }
   4591 
   4592             history_del(&snac, "timeline.html_");
   4593         }
   4594 
   4595         status = HTTP_STATUS_SEE_OTHER;
   4596     }
   4597     else
   4598     if (p_path && strcmp(p_path, "admin/action") == 0) { /** **/
   4599         /* action on an entry */
   4600         const char *id     = xs_dict_get(p_vars, "id");
   4601         const char *actor  = xs_dict_get(p_vars, "actor");
   4602         const char *action = xs_dict_get(p_vars, "action");
   4603         const char *group  = xs_dict_get(p_vars, "group");
   4604 
   4605         if (action == NULL)
   4606             return HTTP_STATUS_NOT_FOUND;
   4607 
   4608         snac_debug(&snac, 1, xs_fmt("web action '%s' received", action));
   4609 
   4610         status = HTTP_STATUS_SEE_OTHER;
   4611 
   4612         if (strcmp(action, L("Like")) == 0) { /** **/
   4613             xs *msg = msg_admiration(&snac, id, "Like");
   4614 
   4615             if (msg != NULL) {
   4616                 enqueue_message(&snac, msg);
   4617                 timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, 1);
   4618             }
   4619         }
   4620         else
   4621         if (strcmp(action, L("Boost")) == 0) { /** **/
   4622             xs *msg = msg_admiration(&snac, id, "Announce");
   4623 
   4624             if (msg != NULL) {
   4625                 enqueue_message(&snac, msg);
   4626                 timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, 0);
   4627             }
   4628         }
   4629         else
   4630         if (strcmp(action, L("Unlike")) == 0) { /** **/
   4631             xs *msg = msg_repulsion(&snac, id, "Like");
   4632 
   4633             if (msg != NULL) {
   4634                 enqueue_message(&snac, msg);
   4635             }
   4636         }
   4637         else
   4638         if (strcmp(action, L("Unboost")) == 0) { /** **/
   4639             xs *msg = msg_repulsion(&snac, id, "Announce");
   4640 
   4641             if (msg != NULL) {
   4642                 enqueue_message(&snac, msg);
   4643             }
   4644         }
   4645         else
   4646         if (strcmp(action, L("MUTE")) == 0) { /** **/
   4647             mute(&snac, actor);
   4648         }
   4649         else
   4650         if (strcmp(action, L("Unmute")) == 0) { /** **/
   4651             unmute(&snac, actor);
   4652         }
   4653         else
   4654         if (strcmp(action, L("Hide")) == 0) { /** **/
   4655             if (is_draft(&snac, id))
   4656                 draft_del(&snac, id);
   4657             else
   4658             if (is_scheduled(&snac, id))
   4659                 schedule_del(&snac, id);
   4660             else
   4661                 hide(&snac, id);
   4662         }
   4663         else
   4664         if (strcmp(action, L("Limit")) == 0) { /** **/
   4665             limit(&snac, actor);
   4666         }
   4667         else
   4668         if (strcmp(action, L("Unlimit")) == 0) { /** **/
   4669             unlimit(&snac, actor);
   4670         }
   4671         else
   4672         if (strcmp(action, L("Follow")) == 0) { /** **/
   4673             xs *msg = msg_follow(&snac, actor);
   4674 
   4675             if (msg != NULL) {
   4676                 /* reload the actor from the message, in may be different */
   4677                 actor = xs_dict_get(msg, "object");
   4678 
   4679                 following_add(&snac, actor, msg);
   4680 
   4681                 enqueue_output_by_actor(&snac, msg, actor, 0);
   4682             }
   4683         }
   4684         else
   4685         if (strcmp(action, L("Unfollow")) == 0) { /** **/
   4686             /* get the following object */
   4687             xs *object = NULL;
   4688 
   4689             if (valid_status(following_get(&snac, actor, &object))) {
   4690                 xs *msg = msg_undo(&snac, xs_dict_get(object, "object"));
   4691 
   4692                 following_del(&snac, actor);
   4693 
   4694                 enqueue_output_by_actor(&snac, msg, actor, 0);
   4695 
   4696                 snac_log(&snac, xs_fmt("unfollowed actor %s", actor));
   4697             }
   4698             else
   4699                 snac_log(&snac, xs_fmt("actor is not being followed %s", actor));
   4700         }
   4701         else
   4702         if (strcmp(action, L("Follow Group")) == 0) { /** **/
   4703             xs *msg = msg_follow(&snac, group);
   4704 
   4705             if (msg != NULL) {
   4706                 /* reload the group from the message, in may be different */
   4707                 group = xs_dict_get(msg, "object");
   4708 
   4709                 following_add(&snac, group, msg);
   4710 
   4711                 enqueue_output_by_actor(&snac, msg, group, 0);
   4712             }
   4713         }
   4714         else
   4715         if (strcmp(action, L("Unfollow Group")) == 0) { /** **/
   4716             /* get the following object */
   4717             xs *object = NULL;
   4718 
   4719             if (valid_status(following_get(&snac, group, &object))) {
   4720                 xs *msg = msg_undo(&snac, xs_dict_get(object, "object"));
   4721 
   4722                 following_del(&snac, group);
   4723 
   4724                 enqueue_output_by_actor(&snac, msg, group, 0);
   4725 
   4726                 snac_log(&snac, xs_fmt("unfollowed group %s", group));
   4727             }
   4728             else
   4729                 snac_log(&snac, xs_fmt("actor is not being followed %s", actor));
   4730         }
   4731         else
   4732         if (strcmp(action, L("Delete")) == 0) { /** **/
   4733             const char *actor_form = xs_dict_get(p_vars, "actor-form");
   4734             if (actor_form != NULL) {
   4735                 /* delete follower */
   4736                 if (valid_status(follower_del(&snac, actor)))
   4737                     snac_log(&snac, xs_fmt("deleted follower %s", actor));
   4738                 else
   4739                     snac_log(&snac, xs_fmt("error deleting follower %s", actor));
   4740             }
   4741             else {
   4742                 /* delete an entry */
   4743                 if (xs_startswith(id, snac.actor) && !is_draft(&snac, id)) {
   4744                     /* it's a post by us: generate a delete */
   4745                     xs *msg = msg_delete(&snac, id);
   4746 
   4747                     enqueue_message(&snac, msg);
   4748 
   4749                     snac_log(&snac, xs_fmt("posted tombstone for %s", id));
   4750                 }
   4751 
   4752                 timeline_del(&snac, id);
   4753 
   4754                 draft_del(&snac, id);
   4755 
   4756                 schedule_del(&snac, id);
   4757 
   4758                 snac_log(&snac, xs_fmt("deleted entry %s", id));
   4759             }
   4760         }
   4761         else
   4762         if (strcmp(action, L("Pin")) == 0) { /** **/
   4763             pin(&snac, id);
   4764             timeline_touch(&snac);
   4765         }
   4766         else
   4767         if (strcmp(action, L("Unpin")) == 0) { /** **/
   4768             unpin(&snac, id);
   4769             timeline_touch(&snac);
   4770         }
   4771         else
   4772         if (strcmp(action, L("Bookmark")) == 0) { /** **/
   4773             bookmark(&snac, id);
   4774             timeline_touch(&snac);
   4775         }
   4776         else
   4777         if (strcmp(action, L("Unbookmark")) == 0) { /** **/
   4778             unbookmark(&snac, id);
   4779             timeline_touch(&snac);
   4780         }
   4781         else
   4782         if (strcmp(action, L("Approve")) == 0) { /** **/
   4783             xs *fwreq = pending_get(&snac, actor);
   4784 
   4785             if (fwreq != NULL) {
   4786                 xs *reply = msg_accept(&snac, fwreq, actor);
   4787 
   4788                 enqueue_message(&snac, reply);
   4789 
   4790                 if (xs_is_null(xs_dict_get(fwreq, "published"))) {
   4791                     /* add a date if it doesn't include one (Mastodon) */
   4792                     xs *date = xs_str_utctime(0, ISO_DATE_SPEC);
   4793                     fwreq = xs_dict_set(fwreq, "published", date);
   4794                 }
   4795 
   4796                 timeline_add(&snac, xs_dict_get(fwreq, "id"), fwreq);
   4797 
   4798                 follower_add(&snac, actor);
   4799 
   4800                 pending_del(&snac, actor);
   4801 
   4802                 snac_log(&snac, xs_fmt("new follower %s", actor));
   4803             }
   4804         }
   4805         else
   4806         if (strcmp(action, L("Discard")) == 0) { /** **/
   4807             pending_del(&snac, actor);
   4808         }
   4809         else
   4810             status = HTTP_STATUS_NOT_FOUND;
   4811 
   4812         /* delete the cached timeline */
   4813         if (status == HTTP_STATUS_SEE_OTHER)
   4814             history_del(&snac, "timeline.html_");
   4815     }
   4816     else
   4817     if (p_path && strcmp(p_path, "admin/user-setup") == 0) { /** **/
   4818         /* change of user data */
   4819         const char *v;
   4820         const char *p1, *p2;
   4821 
   4822         if ((v = xs_dict_get(p_vars, "name")) != NULL)
   4823             snac.config = xs_dict_set(snac.config, "name", v);
   4824         if ((v = xs_dict_get(p_vars, "avatar")) != NULL)
   4825             snac.config = xs_dict_set(snac.config, "avatar", v);
   4826         if ((v = xs_dict_get(p_vars, "bio")) != NULL)
   4827             snac.config = xs_dict_set(snac.config, "bio", v);
   4828         if ((v = xs_dict_get(p_vars, "cw")) != NULL &&
   4829             strcmp(v, "on") == 0) {
   4830             snac.config = xs_dict_set(snac.config, "cw", "open");
   4831         } else { /* if the checkbox is not set, the parameter is missing */
   4832             snac.config = xs_dict_set(snac.config, "cw", "");
   4833         }
   4834         if ((v = xs_dict_get(p_vars, "email")) != NULL)
   4835             snac.config = xs_dict_set(snac.config, "email", v);
   4836         if ((v = xs_dict_get(p_vars, "telegram_bot")) != NULL)
   4837             snac.config = xs_dict_set(snac.config, "telegram_bot", v);
   4838         if ((v = xs_dict_get(p_vars, "telegram_chat_id")) != NULL)
   4839             snac.config = xs_dict_set(snac.config, "telegram_chat_id", v);
   4840         if ((v = xs_dict_get(p_vars, "ntfy_server")) != NULL)
   4841             snac.config = xs_dict_set(snac.config, "ntfy_server", v);
   4842         if ((v = xs_dict_get(p_vars, "ntfy_token")) != NULL)
   4843             snac.config = xs_dict_set(snac.config, "ntfy_token", v);
   4844         if ((v = xs_dict_get(p_vars, "purge_days")) != NULL) {
   4845             xs *days    = xs_number_new(atof(v));
   4846             snac.config = xs_dict_set(snac.config, "purge_days", days);
   4847         }
   4848         if ((v = xs_dict_get(p_vars, "drop_dm_from_unknown")) != NULL && strcmp(v, "on") == 0)
   4849             snac.config = xs_dict_set(snac.config, "drop_dm_from_unknown", xs_stock(XSTYPE_TRUE));
   4850         else
   4851             snac.config = xs_dict_set(snac.config, "drop_dm_from_unknown", xs_stock(XSTYPE_FALSE));
   4852         if ((v = xs_dict_get(p_vars, "bot")) != NULL && strcmp(v, "on") == 0)
   4853             snac.config = xs_dict_set(snac.config, "bot", xs_stock(XSTYPE_TRUE));
   4854         else
   4855             snac.config = xs_dict_set(snac.config, "bot", xs_stock(XSTYPE_FALSE));
   4856         if ((v = xs_dict_get(p_vars, "private")) != NULL && strcmp(v, "on") == 0)
   4857             snac.config = xs_dict_set(snac.config, "private", xs_stock(XSTYPE_TRUE));
   4858         else
   4859             snac.config = xs_dict_set(snac.config, "private", xs_stock(XSTYPE_FALSE));
   4860         if ((v = xs_dict_get(p_vars, "auto_boost")) != NULL && strcmp(v, "on") == 0)
   4861             snac.config = xs_dict_set(snac.config, "auto_boost", xs_stock(XSTYPE_TRUE));
   4862         else
   4863             snac.config = xs_dict_set(snac.config, "auto_boost", xs_stock(XSTYPE_FALSE));
   4864         if ((v = xs_dict_get(p_vars, "collapse_threads")) != NULL && strcmp(v, "on") == 0)
   4865             snac.config = xs_dict_set(snac.config, "collapse_threads", xs_stock(XSTYPE_TRUE));
   4866         else
   4867             snac.config = xs_dict_set(snac.config, "collapse_threads", xs_stock(XSTYPE_FALSE));
   4868         if ((v = xs_dict_get(p_vars, "approve_followers")) != NULL && strcmp(v, "on") == 0)
   4869             snac.config = xs_dict_set(snac.config, "approve_followers", xs_stock(XSTYPE_TRUE));
   4870         else
   4871             snac.config = xs_dict_set(snac.config, "approve_followers", xs_stock(XSTYPE_FALSE));
   4872         if ((v = xs_dict_get(p_vars, "show_contact_metrics")) != NULL && strcmp(v, "on") == 0)
   4873             snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_TRUE));
   4874         else
   4875             snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_FALSE));
   4876         if ((v = xs_dict_get(p_vars, "web_ui_lang")) != NULL)
   4877             snac.config = xs_dict_set(snac.config, "lang", v);
   4878         if ((v = xs_dict_get(p_vars, "tz")) != NULL)
   4879             snac.config = xs_dict_set(snac.config, "tz", v);
   4880 
   4881         snac.config = xs_dict_set(snac.config, "latitude", xs_dict_get_def(p_vars, "latitude", ""));
   4882         snac.config = xs_dict_set(snac.config, "longitude", xs_dict_get_def(p_vars, "longitude", ""));
   4883 
   4884         snac.config = xs_dict_set(snac.config, "notify_webhook", xs_dict_get_def(p_vars, "notify_webhook", ""));
   4885 
   4886         if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
   4887             snac.config = xs_dict_set(snac.config, "metadata", v);
   4888 
   4889         /* uploads */
   4890         const char *uploads[] = { "avatar", "header", NULL };
   4891         int n;
   4892         for (n = 0; uploads[n]; n++) {
   4893             xs *var_name = xs_fmt("%s_file", uploads[n]);
   4894 
   4895             const xs_list *uploaded_file = xs_dict_get(p_vars, var_name);
   4896             if (xs_type(uploaded_file) == XSTYPE_LIST) {
   4897                 const char *fn = xs_list_get(uploaded_file, 0);
   4898 
   4899                 if (fn && *fn) {
   4900                     const char *mimetype = xs_mime_by_ext(fn);
   4901 
   4902                     if (xs_startswith(mimetype, "image/")) {
   4903                         const char *ext = strrchr(fn, '.');
   4904                         const char *hash = xs_md5(fn);
   4905                         xs *id          = xs_fmt("%s-%s%s", uploads[n], hash, ext ? ext : "");
   4906                         xs *url         = xs_fmt("%s/s/%s", snac.actor, id);
   4907                         int fo          = xs_number_get(xs_list_get(uploaded_file, 1));
   4908                         int fs          = xs_number_get(xs_list_get(uploaded_file, 2));
   4909 
   4910                         /* store */
   4911                         static_put(&snac, id, payload + fo, fs);
   4912 
   4913                         snac.config = xs_dict_set(snac.config, uploads[n], url);
   4914                     }
   4915                 }
   4916             }
   4917         }
   4918 
   4919         /* delete images by removing url from user.json */
   4920         for (n = 0; uploads[n]; n++) {
   4921             xs *var_name = xs_fmt("%s_delete", uploads[n]);
   4922             const char *delete_var = xs_dict_get(p_vars, var_name);
   4923 
   4924             if (delete_var != NULL && strcmp(delete_var, "on") == 0) {
   4925                     snac.config = xs_dict_set(snac.config, uploads[n], "");
   4926             }
   4927         }
   4928 
   4929         /* password change? */
   4930         if ((p1 = xs_dict_get(p_vars, "passwd1")) != NULL &&
   4931             (p2 = xs_dict_get(p_vars, "passwd2")) != NULL &&
   4932             *p1 && strcmp(p1, p2) == 0) {
   4933             xs *pw = hash_password(snac.uid, p1, NULL);
   4934             snac.config = xs_dict_set(snac.config, "passwd", pw);
   4935         }
   4936 
   4937         user_persist(&snac, 1);
   4938 
   4939         status = HTTP_STATUS_SEE_OTHER;
   4940     }
   4941     else
   4942     if (p_path && strcmp(p_path, "admin/clear-notifications") == 0) { /** **/
   4943         notify_clear(&snac);
   4944         timeline_touch(&snac);
   4945 
   4946         status = HTTP_STATUS_SEE_OTHER;
   4947     }
   4948     else
   4949     if (p_path && strcmp(p_path, "admin/vote") == 0) { /** **/
   4950         const char *irt   = xs_dict_get(p_vars, "irt");
   4951         const char *opt   = xs_dict_get(p_vars, "question");
   4952         const char *actor = xs_dict_get(p_vars, "actor");
   4953 
   4954         xs *ls = NULL;
   4955 
   4956         /* multiple choices? */
   4957         if (xs_type(opt) == XSTYPE_LIST)
   4958             ls = xs_dup(opt);
   4959         else
   4960         if (xs_type(opt) == XSTYPE_STRING) {
   4961             ls = xs_list_new();
   4962             ls = xs_list_append(ls, opt);
   4963         }
   4964 
   4965         const xs_str *v;
   4966         int c = 0;
   4967 
   4968         while (xs_list_next(ls, &v, &c)) {
   4969             xs *msg = msg_note(&snac, "", actor, irt, NULL, 1, NULL, NULL);
   4970 
   4971             /* set the option */
   4972             msg = xs_dict_append(msg, "name", v);
   4973 
   4974             /* delete the content */
   4975             msg = xs_dict_del(msg, "content");
   4976 
   4977             xs *c_msg = msg_create(&snac, msg);
   4978 
   4979             enqueue_message(&snac, c_msg);
   4980 
   4981             timeline_add(&snac, xs_dict_get(msg, "id"), msg);
   4982         }
   4983 
   4984         if (ls != NULL) {
   4985             /* get the poll object */
   4986             xs *poll = NULL;
   4987 
   4988             if (valid_status(object_get(irt, &poll))) {
   4989                 const char *date = xs_dict_get(poll, "endTime");
   4990                 if (xs_is_null(date))
   4991                     date = xs_dict_get(poll, "closed");
   4992 
   4993                 if (!xs_is_null(date)) {
   4994                     time_t t = xs_parse_iso_date(date, 0) - time(NULL);
   4995 
   4996                     /* request the poll when it's closed;
   4997                        Pleroma does not send and update when the poll closes */
   4998                     enqueue_object_request(&snac, irt, t + 2);
   4999                 }
   5000             }
   5001         }
   5002 
   5003         status = HTTP_STATUS_SEE_OTHER;
   5004     }
   5005     else
   5006     if (p_path && strcmp(p_path, "admin/followed-hashtags") == 0) { /** **/
   5007         const char *followed_hashtags = xs_dict_get(p_vars, "followed_hashtags");
   5008 
   5009         if (xs_is_string(followed_hashtags)) {
   5010             xs *new_hashtags = xs_list_new();
   5011             xs *l = xs_split(followed_hashtags, "\n");
   5012             const char *v;
   5013 
   5014             xs_list_foreach(l, v) {
   5015                 xs *s1 = xs_strip_i(xs_dup(v));
   5016                 s1 = xs_replace_i(s1, " ", "");
   5017 
   5018                 if (*s1 == '\0')
   5019                     continue;
   5020 
   5021                 xs *s2 = NULL;
   5022 
   5023                 if (xs_startswith(s1, "https:/"))
   5024                     s2 = xs_dup(s1);
   5025                 else {
   5026                     s2 = xs_utf8_to_lower(s1);
   5027 
   5028                     if (*s2 != '#')
   5029                         s2 = xs_str_prepend_i(s2, "#");
   5030                 }
   5031 
   5032                 new_hashtags = xs_list_append(new_hashtags, s2);
   5033             }
   5034 
   5035             snac.config = xs_dict_set(snac.config, "followed_hashtags", new_hashtags);
   5036             user_persist(&snac, 0);
   5037         }
   5038 
   5039         status = HTTP_STATUS_SEE_OTHER;
   5040     }
   5041     else
   5042     if (p_path && strcmp(p_path, "admin/blocked-hashtags") == 0) { /** **/
   5043         const char *hashtags = xs_dict_get(p_vars, "blocked_hashtags");
   5044 
   5045         if (xs_is_string(hashtags)) {
   5046             xs *new_hashtags = xs_list_new();
   5047             xs *l = xs_split(hashtags, "\n");
   5048             const char *v;
   5049 
   5050             xs_list_foreach(l, v) {
   5051                 xs *s1 = xs_strip_i(xs_dup(v));
   5052                 s1 = xs_replace_i(s1, " ", "");
   5053 
   5054                 if (*s1 == '\0')
   5055                     continue;
   5056 
   5057                 xs *s2 = xs_utf8_to_lower(s1);
   5058                 if (*s2 != '#')
   5059                     s2 = xs_str_prepend_i(s2, "#");
   5060 
   5061                 new_hashtags = xs_list_append(new_hashtags, s2);
   5062             }
   5063 
   5064             snac.config = xs_dict_set(snac.config, "blocked_hashtags", new_hashtags);
   5065             user_persist(&snac, 0);
   5066         }
   5067 
   5068         status = HTTP_STATUS_SEE_OTHER;
   5069     }
   5070 
   5071     if (status == HTTP_STATUS_SEE_OTHER) {
   5072         const char *hard_redir = xs_dict_get(p_vars, "hard-redir");
   5073 
   5074         if (xs_is_string(hard_redir))
   5075             *body = xs_dup(hard_redir);
   5076         else {
   5077             const char *redir = xs_dict_get(p_vars, "redir");
   5078 
   5079             if (xs_is_null(redir))
   5080                 redir = "top";
   5081 
   5082             *body = xs_fmt("%s/admin#%s", snac.actor, redir);
   5083         }
   5084 
   5085         *b_size = strlen(*body);
   5086     }
   5087 
   5088     user_free(&snac);
   5089 
   5090     return status;
   5091 }