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