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