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