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