2.95 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        xs_endswith(date, "Z") && (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, OP_CHECK) == -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, int tb_friendly)
1469/* generates the top controls */
1470{
1471    if (tb_friendly) {
1472        /* generate only a link to the /operations entrypoint */
1473        xs *url = xs_fmt("%s/operations", user->actor);
1474
1475        return xs_html_tag("a",
1476            xs_html_attr("href", url),
1477            xs_html_text(L("Operations...")));
1478    }
1479
1480    xs *ops_action = xs_fmt("%s/admin/action", user->actor);
1481
1482    xs_html *top_controls = xs_html_tag("div",
1483        xs_html_attr("class", "snac-top-controls"),
1484
1485        /** new post **/
1486        html_note(user, L("New Post..."),
1487            "new_post_div", "new_post_form",
1488            L("What's on your mind?"), "",
1489            NULL, NULL,
1490            xs_stock(XSTYPE_FALSE), "",
1491            SCOPE_PUBLIC, NULL,
1492            NULL, 1, NULL, NULL, 0, NULL, NULL),
1493
1494        /** operations **/
1495        xs_html_tag("details",
1496            xs_html_tag("summary",
1497                xs_html_text(L("Operations..."))),
1498            xs_html_tag("p", NULL),
1499            xs_html_tag("form",
1500                xs_html_attr("autocomplete", "off"),
1501                xs_html_attr("method",       "post"),
1502                xs_html_attr("action",       ops_action),
1503                xs_html_sctag("input",
1504                    xs_html_attr("type",     "text"),
1505                    xs_html_attr("name",     "actor"),
1506                    xs_html_attr("required", "required"),
1507                    xs_html_attr("placeholder", "bob@example.com")),
1508                xs_html_text(" "),
1509                xs_html_sctag("input",
1510                    xs_html_attr("type",    "submit"),
1511                    xs_html_attr("name",    "action"),
1512                    xs_html_attr("value",   L("Follow"))),
1513            xs_html_text(" "),
1514            xs_html_text(L("(by URL or user@host)"))),
1515            xs_html_tag("p", NULL),
1516            xs_html_tag("form",
1517                xs_html_attr("autocomplete", "off"),
1518                xs_html_attr("method",       "post"),
1519                xs_html_attr("action",       ops_action),
1520                xs_html_sctag("input",
1521                    xs_html_attr("type",     "url"),
1522                    xs_html_attr("name",     "id"),
1523                    xs_html_attr("required", "required"),
1524                    xs_html_attr("placeholder", "https:/" "/fedi.example.com/bob/...")),
1525                xs_html_text(" "),
1526                xs_html_sctag("input",
1527                    xs_html_attr("type",    "submit"),
1528                    xs_html_attr("name",    "action"),
1529                    xs_html_attr("value",   L("Boost"))),
1530                xs_html_text(" "),
1531                xs_html_text(L("(by URL)"))),
1532            xs_html_tag("p", NULL),
1533            xs_html_tag("form",
1534                xs_html_attr("autocomplete", "off"),
1535                xs_html_attr("method",       "post"),
1536                xs_html_attr("action",       ops_action),
1537                xs_html_sctag("input",
1538                    xs_html_attr("type",     "text"),
1539                    xs_html_attr("name",     "id"),
1540                    xs_html_attr("required", "required"),
1541                    xs_html_attr("placeholder", "https:/" "/fedi.example.com/bob/...")),
1542                xs_html_text(" "),
1543                xs_html_sctag("input",
1544                    xs_html_attr("type",    "submit"),
1545                    xs_html_attr("name",    "action"),
1546                    xs_html_attr("value",   L("Like"))),
1547                xs_html_text(" "),
1548                xs_html_text(L("(by URL)"))),
1549            xs_html_tag("p", NULL),
1550            xs_html_tag("form",
1551                xs_html_attr("autocomplete", "off"),
1552                xs_html_attr("method",       "post"),
1553                xs_html_attr("action",       ops_action),
1554                xs_html_sctag("input",
1555                    xs_html_attr("type",     "text"),
1556                    xs_html_attr("name",     "eid"),
1557                    xs_html_attr("required", "required"),
1558                    xs_html_attr("placeholder", ":neocat:")),
1559                xs_html_text(" "),
1560                xs_html_sctag("input",
1561                    xs_html_attr("type",     "text"),
1562                    xs_html_attr("name",     "id"),
1563                    xs_html_attr("required", "required"),
1564                    xs_html_attr("placeholder", "https:/" "/fedi.example.com/bob/...")),
1565                xs_html_text(" "),
1566                xs_html_sctag("input",
1567                    xs_html_attr("type",    "submit"),
1568                    xs_html_attr("name",    "action"),
1569                    xs_html_attr("value",   L("EmojiReact"))),
1570                xs_html_text(" "),
1571                xs_html_text(L("(by URL)"))),
1572            xs_html_tag("p", NULL)));
1573
1574    /** user settings **/
1575
1576    const char *email = "[disabled by admin]";
1577
1578    if (xs_type(xs_dict_get(srv_config, "disable_email_notifications")) != XSTYPE_TRUE) {
1579        email = xs_dict_get(user->config_o, "email");
1580        if (xs_is_null(email)) {
1581            email = xs_dict_get(user->config, "email");
1582
1583            if (xs_is_null(email))
1584                email = "";
1585        }
1586    }
1587
1588    const char *cw = xs_dict_get(user->config, "cw");
1589    if (xs_is_null(cw))
1590        cw = "";
1591
1592    const char *telegram_bot = xs_dict_get(user->config, "telegram_bot");
1593    if (xs_is_null(telegram_bot))
1594        telegram_bot = "";
1595
1596    const char *telegram_chat_id = xs_dict_get(user->config, "telegram_chat_id");
1597    if (xs_is_null(telegram_chat_id))
1598        telegram_chat_id = "";
1599
1600    const char *ntfy_server = xs_dict_get(user->config, "ntfy_server");
1601    if (xs_is_null(ntfy_server))
1602        ntfy_server = "";
1603
1604    const char *ntfy_token = xs_dict_get(user->config, "ntfy_token");
1605    if (xs_is_null(ntfy_token))
1606        ntfy_token = "";
1607
1608    const char *purge_days = xs_dict_get(user->config, "purge_days");
1609    if (!xs_is_null(purge_days) && xs_type(purge_days) == XSTYPE_NUMBER)
1610        purge_days = (char *)xs_number_str(purge_days);
1611    else
1612        purge_days = "0";
1613
1614    const xs_val *d_dm_f_u  = xs_dict_get(user->config, "drop_dm_from_unknown");
1615    const xs_val *bot       = xs_dict_get(user->config, "bot");
1616    const xs_val *a_private = xs_dict_get(user->config, "private");
1617    const xs_val *auto_boost = xs_dict_get(user->config, "auto_boost");
1618    const xs_val *coll_thrds = xs_dict_get(user->config, "collapse_threads");
1619    const xs_val *pending    = xs_dict_get(user->config, "approve_followers");
1620    const xs_val *show_foll  = xs_dict_get(user->config, "show_contact_metrics");
1621    const xs_val *show_unlisted = xs_dict_get(user->config, "show_unlisted");
1622    const char *latitude     = xs_dict_get_def(user->config, "latitude", "");
1623    const char *longitude    = xs_dict_get_def(user->config, "longitude", "");
1624    const char *webhook      = xs_dict_get_def(user->config, "notify_webhook", "");
1625    const char *post_langs   = xs_dict_get_def(user->config, "post_langs", "");
1626    const char *excluded_langs = xs_dict_get_def(user->config, "excluded_langs", "");
1627    const char *text_browser_uas = xs_dict_get_def(user->config, "text_browser_uas", "");
1628
1629    xs *metadata = NULL;
1630    const xs_dict *md = xs_dict_get(user->config, "metadata");
1631
1632    if (xs_type(md) == XSTYPE_DICT) {
1633        const xs_str *k;
1634        const xs_str *v;
1635
1636        metadata = xs_str_new(NULL);
1637
1638        xs_dict_foreach(md, k, v) {
1639            xs *kp = xs_fmt("%s=%s", k, v);
1640
1641            if (*metadata)
1642                metadata = xs_str_cat(metadata, "\n");
1643            metadata = xs_str_cat(metadata, kp);
1644        }
1645    }
1646    else
1647    if (xs_type(md) == XSTYPE_STRING)
1648        metadata = xs_dup(md);
1649    else
1650        metadata = xs_str_new(NULL);
1651
1652    /* ui language */
1653    xs_html *lang_select = xs_html_tag("select",
1654        xs_html_attr("name", "web_ui_lang"));
1655
1656    const char *u_lang = xs_dict_get_def(user->config, "lang", "en");
1657    const char *lang;
1658    const xs_dict *langs;
1659
1660    xs_dict_foreach(srv_langs, lang, langs) {
1661        if (strcmp(u_lang, lang) == 0)
1662            xs_html_add(lang_select,
1663                xs_html_tag("option",
1664                    xs_html_text(lang),
1665                    xs_html_attr("value", lang),
1666                    xs_html_attr("selected", "selected")));
1667        else
1668            xs_html_add(lang_select,
1669                xs_html_tag("option",
1670                    xs_html_text(lang),
1671                    xs_html_attr("value", lang)));
1672    }
1673
1674    /* timezone */
1675    xs_html *tz_select = xs_html_tag("select",
1676        xs_html_attr("name", "tz"));
1677
1678    xs *tzs = xs_tz_list();
1679    const char *tz;
1680
1681    xs_list_foreach(tzs, tz) {
1682        if (strcmp(tz, user->tz) == 0)
1683            xs_html_add(tz_select,
1684                xs_html_tag("option",
1685                xs_html_text(tz),
1686                xs_html_attr("value", tz),
1687                xs_html_attr("selected", "selected")));
1688        else
1689            xs_html_add(tz_select,
1690                xs_html_tag("option",
1691                xs_html_text(tz),
1692                xs_html_attr("value", tz)));
1693    }
1694
1695    xs *user_setup_action = xs_fmt("%s/admin/user-setup", user->actor);
1696
1697    xs_html_add(top_controls,
1698        xs_html_tag("details",
1699        xs_html_tag("summary",
1700            xs_html_text(L("User Settings..."))),
1701        xs_html_tag("div",
1702            xs_html_attr("class", "snac-user-setup"),
1703            xs_html_tag("form",
1704                xs_html_attr("autocomplete", "off"),
1705                xs_html_attr("method",       "post"),
1706                xs_html_attr("action",       user_setup_action),
1707                xs_html_attr("enctype",      "multipart/form-data"),
1708                xs_html_tag("p",
1709                    xs_html_text(L("Display name:")),
1710                    xs_html_sctag("br", NULL),
1711                    xs_html_sctag("input",
1712                        xs_html_attr("type", "text"),
1713                        xs_html_attr("name", "name"),
1714                        xs_html_attr("value", xs_dict_get(user->config, "name")),
1715                        xs_html_attr("placeholder", L("Your name")))),
1716                xs_html_tag("p",
1717                    xs_html_text(L("Avatar: ")),
1718                    xs_html_sctag("input",
1719                        xs_html_attr("type", "file"),
1720                        xs_html_attr("name", "avatar_file"))),
1721                xs_html_tag("p",
1722                    xs_html_sctag("input",
1723                        xs_html_attr("type", "checkbox"),
1724                        xs_html_attr("name", "avatar_delete")),
1725                        xs_html_text(L("Delete current avatar"))),
1726                xs_html_tag("p",
1727                    xs_html_text(L("Header image (banner): ")),
1728                    xs_html_sctag("input",
1729                        xs_html_attr("type", "file"),
1730                        xs_html_attr("name", "header_file"))),
1731                xs_html_tag("p",
1732                    xs_html_sctag("input",
1733                        xs_html_attr("type", "checkbox"),
1734                        xs_html_attr("name", "header_delete")),
1735                        xs_html_text(L("Delete current header image"))),
1736                xs_html_tag("p",
1737                    xs_html_text(L("Bio:")),
1738                    xs_html_sctag("br", NULL),
1739                    xs_html_tag("textarea",
1740                        xs_html_attr("name", "bio"),
1741                        xs_html_attr("cols", "40"),
1742                        xs_html_attr("rows", "4"),
1743                        xs_html_attr("placeholder", L("Write about yourself here...")),
1744                        xs_html_text(xs_dict_get(user->config, "bio")))),
1745                xs_html_sctag("input",
1746                    xs_html_attr("type", "checkbox"),
1747                    xs_html_attr("name", "cw"),
1748                    xs_html_attr("id",   "cw"),
1749                    xs_html_attr(strcmp(cw, "open") == 0 ? "checked" : "", NULL)),
1750                xs_html_tag("label",
1751                    xs_html_attr("for", "cw"),
1752                    xs_html_text(L("Always show sensitive content"))),
1753                xs_html_tag("p",
1754                    xs_html_text(L("Email address for notifications:")),
1755                    xs_html_sctag("br", NULL),
1756                    xs_html_sctag("input",
1757                        xs_html_attr("type", "text"),
1758                        xs_html_attr("name", "email"),
1759                        xs_html_attr("value", email),
1760                        xs_html_attr("placeholder", "bob@example.com"))),
1761                xs_html_tag("p",
1762                    xs_html_text(L("Telegram notifications (bot key and chat id):")),
1763                    xs_html_sctag("br", NULL),
1764                    xs_html_sctag("input",
1765                        xs_html_attr("type", "text"),
1766                        xs_html_attr("name", "telegram_bot"),
1767                        xs_html_attr("value", telegram_bot),
1768                        xs_html_attr("placeholder", L("Bot API key"))),
1769                    xs_html_text(" "),
1770                    xs_html_sctag("input",
1771                        xs_html_attr("type", "text"),
1772                        xs_html_attr("name", "telegram_chat_id"),
1773                        xs_html_attr("value", telegram_chat_id),
1774                        xs_html_attr("placeholder", L("Chat id")))),
1775                xs_html_tag("p",
1776                    xs_html_text(L("ntfy notifications (ntfy server and token):")),
1777                    xs_html_sctag("br", NULL),
1778                    xs_html_sctag("input",
1779                        xs_html_attr("type", "text"),
1780                        xs_html_attr("name", "ntfy_server"),
1781                        xs_html_attr("value", ntfy_server),
1782                        xs_html_attr("placeholder", L("ntfy server - full URL (example: https://ntfy.sh/YourTopic)"))),
1783                    xs_html_text(" "),
1784                    xs_html_sctag("input",
1785                        xs_html_attr("type", "text"),
1786                        xs_html_attr("name", "ntfy_token"),
1787                        xs_html_attr("value", ntfy_token),
1788                        xs_html_attr("placeholder", L("ntfy token - if needed")))),
1789                xs_html_tag("p",
1790                    xs_html_text(L("Notify webhook:")),
1791                    xs_html_sctag("br", NULL),
1792                    xs_html_sctag("input",
1793                        xs_html_attr("type", "url"),
1794                        xs_html_attr("name", "notify_webhook"),
1795                        xs_html_attr("value", webhook),
1796                        xs_html_attr("placeholder", L("http://example.com/webhook")))),
1797                xs_html_tag("p",
1798                    xs_html_text(L("Maximum days to keep posts (0: server settings):")),
1799                    xs_html_sctag("br", NULL),
1800                    xs_html_sctag("input",
1801                        xs_html_attr("type", "number"),
1802                        xs_html_attr("name", "purge_days"),
1803                        xs_html_attr("value", purge_days))),
1804                html_checkbox("drop_dm_from_unknown", L("Drop direct messages from people you don't follow"),
1805                        xs_is_true(d_dm_f_u)),
1806                html_checkbox("bot", L("This account is a bot"),
1807                        xs_is_true(bot)),
1808                html_checkbox("auto_boost", L("Auto-boost all mentions to this account"),
1809                        xs_is_true(auto_boost)),
1810                html_checkbox("private", L("This account is private "
1811                        "(posts are not shown through the web)"),
1812                        xs_is_true(a_private)),
1813                html_checkbox("collapse_threads", L("Collapse top threads by default"),
1814                        xs_is_true(coll_thrds)),
1815                html_checkbox("approve_followers", L("Follow requests must be approved"),
1816                        xs_is_true(pending)),
1817                html_checkbox("show_contact_metrics", L("Publish follower and following metrics"),
1818                        xs_is_true(show_foll)),
1819                html_checkbox("show_unlisted", L("Show unlisted posts in the public page and RSS feed"),
1820                        xs_is_true(show_unlisted)),
1821                xs_html_tag("p",
1822                    xs_html_text(L("Current location:")),
1823                    xs_html_sctag("br", NULL),
1824                    xs_html_sctag("input",
1825                        xs_html_attr("type", "text"),
1826                        xs_html_attr("name", "latitude"),
1827                        xs_html_attr("value", latitude),
1828                        xs_html_attr("placeholder", "latitude")),
1829                    xs_html_text(" "),
1830                    xs_html_sctag("input",
1831                        xs_html_attr("type", "text"),
1832                        xs_html_attr("name", "longitude"),
1833                        xs_html_attr("value", longitude),
1834                        xs_html_attr("placeholder", "longitude"))),
1835                xs_html_tag("p",
1836                    xs_html_text(L("Profile metadata (key=value pairs in each line):")),
1837                    xs_html_sctag("br", NULL),
1838                    xs_html_tag("textarea",
1839                        xs_html_attr("name", "metadata"),
1840                        xs_html_attr("cols", "40"),
1841                        xs_html_attr("rows", "4"),
1842                        xs_html_attr("placeholder", "Blog=https:/"
1843                            "/example.com/my-blog\nGPG Key=1FA54\n..."),
1844                    xs_html_text(metadata))),
1845
1846                xs_html_tag("p",
1847                    xs_html_text(L("Web interface language:")),
1848                    xs_html_sctag("br", NULL),
1849                    lang_select),
1850
1851                xs_html_tag("p",
1852                    xs_html_text(L("Time zone:")),
1853                    xs_html_sctag("br", NULL),
1854                    tz_select),
1855
1856                xs_html_tag("p",
1857                    xs_html_text(L("Languages you usually post in:")),
1858                    xs_html_sctag("br", NULL),
1859                    xs_html_sctag("input",
1860                        xs_html_attr("type", "text"),
1861                        xs_html_attr("name", "post_langs"),
1862                        xs_html_attr("value", post_langs),
1863                        xs_html_attr("placeholder", L("en fr es de_AT")))),
1864
1865                xs_html_tag("p",
1866                    xs_html_text(L("Don't show posts written in these languages:")),
1867                    xs_html_sctag("br", NULL),
1868                    xs_html_sctag("input",
1869                        xs_html_attr("type", "text"),
1870                        xs_html_attr("name", "excluded_langs"),
1871                        xs_html_attr("value", excluded_langs),
1872                        xs_html_attr("placeholder", L("en fr es de_AT")))),
1873
1874                xs_html_tag("p",
1875                    xs_html_text(L("Show a simpler web UI for browsers with these user agents (one per line):")),
1876                    xs_html_sctag("br", NULL),
1877                    xs_html_tag("textarea",
1878                        xs_html_attr("name", "text_browser_uas"),
1879                        xs_html_attr("cols", "40"),
1880                        xs_html_attr("rows", "4"),
1881                        xs_html_attr("placeholder", "Links\nLynx\nNetSurf"),
1882                    xs_html_text(text_browser_uas))),
1883
1884                xs_html_tag("p",
1885                    xs_html_text(L("New password:")),
1886                    xs_html_sctag("br", NULL),
1887                    xs_html_sctag("input",
1888                        xs_html_attr("type", "password"),
1889                        xs_html_attr("name", "passwd1"),
1890                        xs_html_attr("value", ""))),
1891                xs_html_tag("p",
1892                    xs_html_text(L("Repeat new password:")),
1893                    xs_html_sctag("br", NULL),
1894                    xs_html_sctag("input",
1895                        xs_html_attr("type", "password"),
1896                        xs_html_attr("name", "passwd2"),
1897                        xs_html_attr("value", ""))),
1898
1899                xs_html_sctag("input",
1900                    xs_html_attr("type", "submit"),
1901                    xs_html_attr("class", "button"),
1902                    xs_html_attr("value", L("Update user info"))),
1903
1904                xs_html_tag("p", NULL)))));
1905
1906    xs *followed_hashtags_action = xs_fmt("%s/admin/followed-hashtags", user->actor);
1907    xs *followed_hashtags = xs_join(xs_dict_get_def(user->config,
1908                        "followed_hashtags", xs_stock(XSTYPE_LIST)), "\n");
1909
1910    xs_html_add(top_controls,
1911        xs_html_tag("details",
1912        xs_html_tag("summary",
1913            xs_html_text(L("Followed hashtags..."))),
1914        xs_html_tag("p",
1915            xs_html_text(L("One hashtag per line"))),
1916        xs_html_tag("div",
1917            xs_html_attr("class", "snac-followed-hashtags"),
1918            xs_html_tag("form",
1919                xs_html_attr("autocomplete", "off"),
1920                xs_html_attr("method", "post"),
1921                xs_html_attr("action", followed_hashtags_action),
1922                xs_html_attr("enctype", "multipart/form-data"),
1923
1924                xs_html_tag("textarea",
1925                    xs_html_attr("name", "followed_hashtags"),
1926                    xs_html_attr("cols", "40"),
1927                    xs_html_attr("rows", "4"),
1928                    xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic\nhttps:/"
1929                        "/mastodon.social/tags/dogs"),
1930                    xs_html_text(followed_hashtags)),
1931
1932                xs_html_tag("br", NULL),
1933
1934                xs_html_sctag("input",
1935                    xs_html_attr("type", "submit"),
1936                    xs_html_attr("class", "button"),
1937                    xs_html_attr("value", L("Update hashtags")))))));
1938
1939    xs *blocked_hashtags_action = xs_fmt("%s/admin/blocked-hashtags", user->actor);
1940    xs *blocked_hashtags = xs_join(xs_dict_get_def(user->config,
1941                        "blocked_hashtags", xs_stock(XSTYPE_LIST)), "\n");
1942
1943    xs_html_add(top_controls,
1944        xs_html_tag("details",
1945        xs_html_tag("summary",
1946            xs_html_text(L("Blocked hashtags..."))),
1947        xs_html_tag("p",
1948            xs_html_text(L("One hashtag per line"))),
1949        xs_html_tag("div",
1950            xs_html_attr("class", "snac-blocked-hashtags"),
1951            xs_html_tag("form",
1952                xs_html_attr("autocomplete", "off"),
1953                xs_html_attr("method", "post"),
1954                xs_html_attr("action", blocked_hashtags_action),
1955                xs_html_attr("enctype", "multipart/form-data"),
1956
1957                xs_html_tag("textarea",
1958                    xs_html_attr("name", "blocked_hashtags"),
1959                    xs_html_attr("cols", "40"),
1960                    xs_html_attr("rows", "4"),
1961                    xs_html_attr("placeholder", "#cats\n#windowfriday\n#classicalmusic"),
1962                    xs_html_text(blocked_hashtags)),
1963
1964                xs_html_tag("br", NULL),
1965
1966                xs_html_sctag("input",
1967                    xs_html_attr("type", "submit"),
1968                    xs_html_attr("class", "button"),
1969                    xs_html_attr("value", L("Update hashtags")))))));
1970
1971    xs *muted_words_action = xs_fmt("%s/admin/muted-words", user->actor);
1972    xs *muted_words = xs_join(xs_dict_get_def(user->config,
1973                        "muted_words", xs_stock(XSTYPE_LIST)), "\n");
1974
1975    xs_html_add(top_controls,
1976        xs_html_tag("details",
1977        xs_html_tag("summary",
1978            xs_html_text(L("Muted words..."))),
1979        xs_html_tag("p",
1980            xs_html_text(L("One word per line, partial matches count"))),
1981        xs_html_tag("div",
1982            xs_html_attr("class", "snac-muted-words"),
1983            xs_html_tag("form",
1984                xs_html_attr("autocomplete", "off"),
1985                xs_html_attr("method", "post"),
1986                xs_html_attr("action", muted_words_action),
1987                xs_html_attr("enctype", "multipart/form-data"),
1988
1989                xs_html_tag("textarea",
1990                    xs_html_attr("name", "muted_words"),
1991                    xs_html_attr("cols", "40"),
1992                    xs_html_attr("rows", "4"),
1993                    xs_html_attr("placeholder", "nascar\nsuperbowl\nFIFA"),
1994                    xs_html_text(muted_words)),
1995
1996                xs_html_tag("br", NULL),
1997
1998                xs_html_sctag("input",
1999                    xs_html_attr("type", "submit"),
2000                    xs_html_attr("class", "button"),
2001                    xs_html_attr("value", L("Update muted words")))))));
2002
2003    return top_controls;
2004}
2005
2006
2007static xs_html *html_button(const char *clss, const char *label, const char *hint)
2008{
2009    xs *c = xs_fmt("snac-btn-%s", clss);
2010
2011    /* use an NULL tag to separate non-css-classed buttons from one another */
2012    return xs_html_container(
2013        xs_html_sctag("input",
2014            xs_html_attr("type",    "submit"),
2015            xs_html_attr("name",    "action"),
2016            xs_html_attr("class",   c),
2017            xs_html_attr("value",   label),
2018            xs_html_attr("title",   hint)),
2019        xs_html_text("\n"));
2020}
2021
2022
2023xs_str *build_mentions(snac *user, const xs_dict *msg)
2024/* returns a string with the mentions in msg */
2025{
2026    xs_str *s = xs_str_new(NULL);
2027    const char *list = xs_dict_get(msg, "tag");
2028    const char *v;
2029    int c = 0;
2030
2031    while (xs_list_next(list, &v, &c)) {
2032        const char *type = xs_dict_get(v, "type");
2033        const char *href = xs_dict_get(v, "href");
2034        const char *name = xs_dict_get(v, "name");
2035
2036        if (type && strcmp(type, "Mention") == 0 &&
2037            href && strcmp(href, user->actor) != 0 && name) {
2038            xs *s1 = NULL;
2039
2040            if (name[0] != '@') {
2041                s1 = xs_fmt("@%s", name);
2042                name = s1;
2043            }
2044
2045            xs *l = xs_split(name, "@");
2046
2047            /* is it a name without a host? */
2048            if (xs_list_len(l) < 3) {
2049                /* split the href and pick the host name LIKE AN ANIMAL */
2050                /* would be better to query the webfinger but *won't do that* here */
2051                xs *l2 = xs_split(href, "/");
2052
2053                if (xs_list_len(l2) >= 3) {
2054                    xs *s1 = xs_fmt("%s@%s ", name, xs_list_get(l2, 2));
2055
2056                    if (xs_str_in(s, s1) == -1)
2057                        s = xs_str_cat(s, s1);
2058                }
2059            }
2060            else {
2061                if (xs_str_in(s, name) == -1) {
2062                    s = xs_str_cat(s, name);
2063                    s = xs_str_cat(s, " ");
2064                }
2065            }
2066        }
2067    }
2068
2069#if 0
2070    /* disabled by now */
2071
2072    /* also add the author of this post as a mention */
2073    const char *atto = get_atto(msg);
2074
2075    if (xs_is_string(atto) && *atto && strcmp(atto, user->actor) != 0) {
2076        /* check if this author is already in the mentions string */
2077        xs *l = xs_split(atto, "/");
2078
2079        if (xs_list_len(l) > 3) {
2080            xs *actor_o = NULL;
2081            if (valid_status(object_get(atto, &actor_o))) {
2082                const char *uname = xs_dict_get(actor_o, "preferredUsername");
2083                if (!xs_is_null(uname) && *uname) {
2084                    xs *handle = xs_fmt("@%s@%s ", uname, xs_list_get(l, 2));
2085                    if (xs_str_in(s, handle) == -1) {
2086                        s = xs_str_cat(s, handle);
2087                    }
2088                }
2089            }
2090        }
2091    }
2092#endif
2093
2094    if (*s) {
2095        xs *s1 = s;
2096        s = xs_fmt("\n\n\nCC: %s", s1);
2097    }
2098
2099    return s;
2100}
2101
2102
2103xs_html *html_entry_controls(snac *user, const char *actor,
2104                            const xs_dict *msg, const char *md5, int tb_friendly)
2105{
2106    const char *id    = xs_dict_get(msg, "id");
2107    const char *group = xs_dict_get(msg, "audience");
2108
2109    xs *likes   = object_likes(id);
2110    xs *boosts  = object_announces(id);
2111
2112    xs *action = xs_fmt("%s/admin/action", user->actor);
2113    xs *redir  = xs_fmt("%s_entry", md5);
2114
2115    xs_html *form;
2116    xs_html *controls = xs_html_tag("div",
2117        xs_html_attr("class", "snac-controls"),
2118        form = xs_html_tag("form",
2119            xs_html_attr("autocomplete", "off"),
2120            xs_html_attr("method",       "post"),
2121            xs_html_attr("action",       action),
2122            xs_html_sctag("input",
2123                xs_html_attr("type",     "hidden"),
2124                xs_html_attr("name",     "id"),
2125                xs_html_attr("value",    id)),
2126            xs_html_sctag("input",
2127                xs_html_attr("type",     "hidden"),
2128                xs_html_attr("name",     "actor"),
2129                xs_html_attr("value",    actor)),
2130            xs_html_sctag("input",
2131                xs_html_attr("type",     "hidden"),
2132                xs_html_attr("name",     "group"),
2133                xs_html_attr("value",    xs_is_null(group) ? "" : group)),
2134            xs_html_sctag("input",
2135                xs_html_attr("type",     "hidden"),
2136                xs_html_attr("name",     "redir"),
2137                xs_html_attr("value",    redir))));
2138
2139    if (!is_msg_mine(user, id)) {
2140        if (xs_list_in(likes, user->md5) == -1) {
2141            /* not already liked; add button */
2142            xs_html_add(form,
2143                html_button("like", L("Like"), L("Say you like this post")));
2144        }
2145        else {
2146            /* not like it anymore */
2147            xs_html_add(form,
2148                html_button("unlike", L("Unlike"), L("Nah don't like it that much")));
2149        }
2150    }
2151    else {
2152        if (is_pinned(user, id))
2153            xs_html_add(form,
2154                html_button("unpin", L("Unpin"), L("Unpin this post from your timeline")));
2155        else
2156            xs_html_add(form,
2157                html_button("pin", L("Pin"), L("Pin this post to the top of your timeline")));
2158    }
2159
2160    if (is_msg_public(msg)) {
2161        if (xs_list_in(boosts, user->md5) == -1) {
2162            /* not already boosted; add button */
2163            xs_html_add(form,
2164                html_button("boost", L("Boost"), L("Announce this post to your followers")));
2165        }
2166        else {
2167            /* already boosted; add button to regret */
2168            xs_html_add(form,
2169                html_button("unboost", L("Unboost"), L("I regret I boosted this")));
2170        }
2171    }
2172
2173    if (is_bookmarked(user, id))
2174            xs_html_add(form,
2175                html_button("unbookmark", L("Unbookmark"), L("Delete this post from your bookmarks")));
2176        else
2177            xs_html_add(form,
2178                html_button("bookmark", L("Bookmark"), L("Add this post to your bookmarks")));
2179
2180    if (strcmp(actor, user->actor) != 0) {
2181        /* controls for other actors than this one */
2182        if (following_check(user, actor)) {
2183            xs_html_add(form,
2184                html_button("unfollow", L("Unfollow"), L("Stop following this user's activity")));
2185        }
2186        else {
2187            xs_html_add(form,
2188                html_button("follow", L("Follow"), L("Start following this user's activity")));
2189        }
2190
2191        if (!xs_is_null(group)) {
2192            if (following_check(user, group)) {
2193                xs_html_add(form,
2194                    html_button("unfollow", L("Unfollow Group"),
2195                        L("Stop following this group or channel")));
2196            }
2197            else {
2198                xs_html_add(form,
2199                    html_button("follow", L("Follow Group"),
2200                        L("Start following this group or channel")));
2201            }
2202        }
2203
2204        xs_html_add(form,
2205            html_button("mute", L("MUTE"),
2206                L("Block any activity from this user forever")));
2207    }
2208
2209    if (!xs_is_true(xs_dict_get(srv_config, "hide_delete_post_button")))
2210        xs_html_add(form,
2211            html_button("delete", L("Delete"), L("Delete this post")));
2212
2213    xs_html_add(form,
2214        html_button("hide",   L("Hide"), L("Hide this post and its children")));
2215
2216    if (tb_friendly) {
2217        /* generate a link a More... text pointing to the one-post-only timeline */
2218        xs *url = xs_fmt("%s/admin/p/%s#%s_entry", user->actor, md5, md5);
2219
2220        xs_html_add(controls,
2221            xs_html_tag("p", NULL),
2222            xs_html_tag("a",
2223                xs_html_attr("href", url),
2224                xs_html_text(L("More..."))));
2225
2226        /* and DONE */
2227
2228        return controls;
2229    }
2230
2231    const char *prev_src = xs_dict_get(msg, "sourceContent");
2232
2233    if (!xs_is_null(prev_src) && strcmp(actor, user->actor) == 0) { /** edit **/
2234        /* post can be edited */
2235        xs *div_id  = xs_fmt("%s_edit", md5);
2236        xs *form_id = xs_fmt("%s_edit_form", md5);
2237        xs *redir   = xs_fmt("%s_entry", md5);
2238
2239        xs *att_files = xs_list_new();
2240        xs *att_alt_texts = xs_list_new();
2241
2242        const xs_list *att_list = xs_dict_get(msg, "attachment");
2243
2244        if (xs_is_list(att_list)) {
2245            const xs_dict *d;
2246
2247            xs_list_foreach(att_list, d) {
2248                const char *att_file = xs_dict_get(d, "url");
2249                const char *att_alt_text = xs_dict_get(d, "name");
2250
2251                if (xs_is_string(att_file) && xs_is_string(att_alt_text)) {
2252                    att_files = xs_list_append(att_files, att_file);
2253                    att_alt_texts = xs_list_append(att_alt_texts, att_alt_text);
2254                }
2255            }
2256        }
2257
2258        const char *note_lang = NULL;
2259        const xs_dict *cmap = xs_dict_get(msg, "contentMap");
2260        if (xs_is_dict(cmap)) {
2261            const char *dummy;
2262            int c = 0;
2263
2264            xs_dict_next(cmap, &note_lang, &dummy, &c);
2265        }
2266
2267        const int edit_scope = get_msg_visibility(msg);
2268
2269        xs_html_add(controls, xs_html_tag("div",
2270            xs_html_tag("p", NULL),
2271            html_note(user, L("Edit..."),
2272                div_id, form_id,
2273                "", prev_src,
2274                id, NULL,
2275                xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
2276                edit_scope, redir,
2277                NULL, 0, att_files, att_alt_texts, is_draft(user, id),
2278                xs_dict_get(msg, "published"), note_lang)),
2279            xs_html_tag("p", NULL));
2280    }
2281
2282    if (!xs_is_true(xs_dict_get(srv_config, "disable_emojireact"))) { /** emoji react **/
2283        /* the post textarea */
2284        xs *div_id  = xs_fmt("%s_reply", md5);
2285        xs *form_id = xs_fmt("%s_reply_form", md5);
2286        xs *e_react = emoji_reacted(user, id);
2287
2288        xs_html_add(controls, xs_html_tag("div",
2289            xs_html_tag("p", NULL),
2290            html_emoji(
2291                user, L("Emoji react..."),
2292                div_id, form_id,
2293                ":neocat:", id,
2294                e_react)),
2295            xs_html_tag("p", NULL));
2296    }
2297
2298    { /** reply **/
2299        /* the post textarea */
2300        xs *ct      = build_mentions(user, msg);
2301        xs *div_id  = xs_fmt("%s_reply", md5);
2302        xs *form_id = xs_fmt("%s_reply_form", md5);
2303        xs *redir   = xs_fmt("%s_entry", md5);
2304
2305        const int scope = get_msg_visibility(msg);
2306
2307        xs_html_add(controls, xs_html_tag("div",
2308            xs_html_tag("p", NULL),
2309            html_note(user, L("Reply..."),
2310                div_id, form_id,
2311                "", ct,
2312                NULL, NULL,
2313                xs_dict_get(msg, "sensitive"), xs_dict_get(msg, "summary"),
2314                scope, redir,
2315                id, 0, NULL, NULL, 0, NULL, NULL)),
2316            xs_html_tag("p", NULL));
2317    }
2318
2319    return controls;
2320}
2321
2322
2323static const xs_str* words_in_content(const xs_list *words, const xs_val *content)
2324/* returns a word that matches any of the words in content */
2325{
2326    if (!xs_is_list(words) || !xs_is_string(content)) {
2327        return NULL;
2328    }
2329    xs *c = xs_split(content, " ");
2330    xs *sc = xs_list_sort(c, NULL);
2331
2332    const xs_str *wv;
2333    const xs_str *cv;
2334    xs_list_foreach(words, wv) {
2335        xs_list_foreach(sc, cv) {
2336            xs_tolower_i((xs_str*)cv);
2337            if(xs_str_in(cv, wv) != -1)
2338                return wv;
2339        }
2340    }
2341
2342    return NULL;
2343}
2344
2345
2346xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
2347                   int level, const char *md5, int hide_children, int tb_friendly)
2348{
2349    const char *id    = xs_dict_get(msg, "id");
2350    const char *type  = xs_dict_get(msg, "type");
2351    const char *actor;
2352    const char *v;
2353    int has_title = 0;
2354    int collapse_threads = 0;
2355    const char *proxy = NULL;
2356
2357    if (user && !read_only && xs_is_true(xs_dict_get(srv_config, "proxy_media")))
2358        proxy = user->actor;
2359
2360    /* do not show non-public messages in the public timeline */
2361    if ((read_only || !user) && !is_msg_public(msg))
2362        return NULL;
2363
2364    if (id && is_instance_blocked(id))
2365        return NULL;
2366
2367    if (user && level == 0 && xs_is_true(xs_dict_get(user->config, "collapse_threads")))
2368        collapse_threads = 1;
2369
2370    /* hidden? do nothing more for this conversation */
2371    if (user && is_hidden(user, id)) {
2372        xs *s1 = xs_fmt("%s_entry", md5);
2373
2374        /* return just an dummy anchor, to keep position after hitting 'Hide' */
2375        return xs_html_tag("div",
2376            xs_html_tag("a",
2377                xs_html_attr("name", s1)));
2378    }
2379
2380    /* avoid too deep nesting, as it may be a loop */
2381    if (level >= MAX_CONVERSATION_LEVELS)
2382        return xs_html_tag("mark",
2383            xs_html_text(L("Truncated (too deep)")));
2384
2385    const char *lang = NULL;
2386    const xs_dict *cmap = xs_dict_get(msg, "contentMap");
2387    if (xs_is_dict(cmap)) {
2388        const char *dummy;
2389        int c = 0;
2390
2391        xs_dict_next(cmap, &lang, &dummy, &c);
2392
2393        if (user && xs_is_string(lang)) {
2394            /* discard posts in excluded languages */
2395            const char *excluded_langs = xs_dict_get(user->config, "excluded_langs");
2396
2397            if (xs_is_string(excluded_langs) &&
2398                xs_str_in(excluded_langs, lang) != -1) {
2399                snac_debug(user, 1, xs_fmt("excluded post in language '%s'", lang));
2400                return NULL;
2401            }
2402        }
2403    }
2404
2405    if (strcmp(type, "Follow") == 0) {
2406        return xs_html_tag("div",
2407            xs_html_attr("class", "snac-post"),
2408            xs_html_tag("div",
2409                xs_html_attr("class", "snac-post-header"),
2410                xs_html_tag("div",
2411                    xs_html_attr("class", "snac-origin"),
2412                    xs_html_text(L("follows you"))),
2413                html_msg_icon(read_only ? NULL : user, xs_dict_get(msg, "actor"), msg, proxy, NULL, lang)));
2414    }
2415    else
2416    if (!xs_match(type, POSTLIKE_OBJECT_TYPE)) {
2417        /* skip oddities */
2418        snac_debug(user, 1, xs_fmt("html_entry: ignoring object type '%s' %s", type, id));
2419        return NULL;
2420    }
2421
2422    /* ignore notes with "name", as they are votes to Questions */
2423    if (strcmp(type, "Note") == 0 && !xs_is_null(xs_dict_get(msg, "name")))
2424        return NULL;
2425
2426    /* get the attributedTo */
2427    if ((actor = get_atto(msg)) == NULL)
2428        return NULL;
2429
2430    /* ignore muted morons immediately */
2431    if (user && is_muted(user, actor)) {
2432        xs *s1 = xs_fmt("%s_entry", md5);
2433
2434        /* return just an dummy anchor, to keep position after hitting 'MUTE' */
2435        return xs_html_tag("div",
2436            xs_html_tag("a",
2437                xs_html_attr("name", s1)));
2438    }
2439
2440    /* don't show followers-only notes from not followed users */
2441    if (user && get_msg_visibility(msg) == SCOPE_FOLLOWERS &&
2442        strcmp(user->actor, actor) != 0 && following_check(user, actor) == 0) {
2443        return NULL;
2444    }
2445
2446    if ((user == NULL || strcmp(actor, user->actor) != 0)
2447        && !valid_status(actor_get(actor, NULL))) {
2448
2449        if (user)
2450            enqueue_actor_refresh(user, actor, 0);
2451
2452        return NULL;
2453    }
2454
2455    /** html_entry top tag **/
2456    xs_html *entry_top = xs_html_tag("div", NULL);
2457
2458    {
2459        xs *s1 = xs_fmt("%s_entry", md5);
2460        xs_html_add(entry_top,
2461            xs_html_tag("a",
2462                xs_html_attr("name", s1)));
2463    }
2464
2465    xs_html *entry = xs_html_tag("div",
2466        xs_html_attr("class", level == 0 ? "snac-post" : "snac-child"));
2467
2468    xs_html_add(entry_top,
2469        entry);
2470
2471    /** post header **/
2472
2473    xs_html *score;
2474    xs_html *post_header = xs_html_tag("div",
2475        xs_html_attr("class", "snac-post-header"),
2476        score = xs_html_tag("div",
2477            xs_html_attr("class", "snac-score")));
2478
2479    xs_html_add(entry,
2480        post_header);
2481
2482    if (user && is_pinned(user, id)) {
2483        /* add a pin emoji */
2484        xs_html_add(score,
2485            xs_html_tag("span",
2486                xs_html_attr("title", L("Pinned")),
2487                xs_html_raw(" &#128204; ")));
2488    }
2489
2490    if (user && !read_only && is_bookmarked(user, id)) {
2491        /* add a bookmark emoji */
2492        xs_html_add(score,
2493            xs_html_tag("span",
2494                xs_html_attr("title", L("Bookmarked")),
2495                xs_html_raw(" &#128278; ")));
2496    }
2497
2498    if (strcmp(type, "Question") == 0) {
2499        /* add the ballot box emoji */
2500        xs_html_add(score,
2501            xs_html_tag("span",
2502                xs_html_attr("title", L("Poll")),
2503                xs_html_raw(" &#128499; ")));
2504
2505        if (user && was_question_voted(user, id)) {
2506            /* add a check to show this poll was voted */
2507            xs_html_add(score,
2508                xs_html_tag("span",
2509                    xs_html_attr("title", L("Voted")),
2510                    xs_html_raw(" &#10003; ")));
2511        }
2512    }
2513
2514    if (strcmp(type, "Event") == 0) {
2515        /* add the calendar emoji */
2516        xs_html_add(score,
2517            xs_html_tag("span",
2518                xs_html_attr("title", L("Event")),
2519                xs_html_raw(" &#128197; ")));
2520    }
2521
2522    /* if it's a user from this same instance, add the score */
2523    if (xs_startswith(id, srv_baseurl)) {
2524        int n_likes  = object_likes_len(id);
2525        int n_boosts = object_announces_len(id);
2526
2527        /* alternate emojis: %d &#128077; %d &#128257; */
2528        xs *s1 = xs_fmt("%d &#9733; %d &#8634;\n", n_likes, n_boosts);
2529
2530        xs_html_add(score,
2531            xs_html_raw(s1));
2532    }
2533
2534    xs *boosts = object_announces(id);
2535
2536    if (xs_list_len(boosts)) {
2537        /* if somebody boosted this, show as origin */
2538        const char *p = xs_list_get(boosts, -1);
2539        xs *actor_r = NULL;
2540
2541        if (user && xs_list_in(boosts, user->md5) != -1) {
2542            /* we boosted this */
2543            xs_html_add(post_header,
2544                xs_html_tag("div",
2545                    xs_html_attr("class", "snac-origin"),
2546                    xs_html_tag("a",
2547                        xs_html_attr("href", user->actor),
2548                        xs_html_text(xs_dict_get(user->config, "name"))),
2549                        xs_html_text(" "),
2550                        xs_html_text(L("boosted"))));
2551        }
2552        else
2553        if (valid_status(object_get_by_md5(p, &actor_r))) {
2554            xs *name = actor_name(actor_r, proxy);
2555
2556            if (!xs_is_null(name)) {
2557                xs *href = NULL;
2558                const char *id = xs_dict_get(actor_r, "id");
2559                int fwers = 0;
2560                int fwing = 0;
2561
2562                if (user != NULL) {
2563                    fwers = follower_check(user, id);
2564                    fwing = following_check(user, id);
2565                }
2566
2567                if (!read_only && (fwers || fwing))
2568                    href = xs_fmt("%s/people/%s", user->actor, p);
2569                else
2570                    href = xs_dup(id);
2571
2572                xs_html_add(post_header,
2573                    xs_html_tag("div",
2574                        xs_html_attr("class", "snac-origin"),
2575                        xs_html_tag("a",
2576                            xs_html_attr("href", href),
2577                            xs_html_raw(name)), /* already sanitized */
2578                            xs_html_text(" "),
2579                            xs_html_text(L("boosted"))));
2580            }
2581        }
2582    }
2583
2584    if (user && strcmp(type, "Note") == 0) {
2585        /* is the parent not here? */
2586        const char *parent = get_in_reply_to(msg);
2587
2588        if (!xs_is_null(parent) && *parent) {
2589            if (!timeline_here(user, parent)) {
2590                xs_html_add(post_header,
2591                    xs_html_tag("div",
2592                        xs_html_attr("class", "snac-origin"),
2593                        xs_html_text(L("in reply to")),
2594                        xs_html_text(" "),
2595                        xs_html_tag("a",
2596                            xs_html_attr("href", parent),
2597                            xs_html_text("»"))));
2598            }
2599        }
2600    }
2601
2602    xs_html_add(post_header,
2603        html_msg_icon(read_only ? NULL : user, actor, msg, proxy, md5, lang));
2604
2605    /** post content **/
2606
2607    xs_html *snac_content_wrap = xs_html_tag("div",
2608        xs_html_attr("class", "e-content snac-content"));
2609
2610    if (xs_is_string(lang))
2611        xs_html_add(snac_content_wrap,
2612            xs_html_attr("lang", lang));
2613
2614    xs_html_add(entry,
2615        snac_content_wrap);
2616
2617    if (!has_title && !xs_is_null(v = xs_dict_get(msg, "name"))) {
2618        xs_html_add(snac_content_wrap,
2619            xs_html_tag("h3",
2620                xs_html_attr("class", "snac-entry-title"),
2621                xs_html_text(v)));
2622
2623        has_title = 1;
2624    }
2625
2626    xs_html *snac_content = NULL;
2627
2628    v = xs_dict_get(msg, "summary");
2629
2630    /* if it has summary or marked as sensitive - add CW */
2631    /* come clients don't set "sensitive" flag properly */
2632    if ((!xs_is_null(v) && *v) || xs_type(xs_dict_get(msg, "sensitive")) == XSTYPE_TRUE) {
2633        if (xs_is_null(v) || *v == '\0')
2634            v = "...";
2635
2636        const char *cw = "";
2637
2638        if (user) {
2639            /* only show it when not in the public timeline and the config setting is "open" */
2640            cw = xs_dict_get(user->config, "cw");
2641            if (xs_is_null(cw) || read_only)
2642                cw = "";
2643        }
2644
2645        /* summary can't contain HTML */
2646        xs *s1 = xs_regex_replace(v, "<[^>]+>", "");
2647
2648        /* crop stupidly long summaries */
2649        if (xs_utf8_len(s1) > 1024) {
2650            s1 = xs_utf8_crop_i(s1, 0, 1024);
2651            s1 = xs_str_cat(s1, "...");
2652        }
2653
2654        snac_content = xs_html_tag("details",
2655            xs_html_attr(cw, NULL),
2656            xs_html_tag("summary",
2657                xs_html_text(s1),
2658                xs_html_text(L(" [SENSITIVE CONTENT]"))));
2659    }
2660    else
2661    if (user &&
2662        /* muted_words is all lowercase and sorted for performance */
2663        (v = words_in_content(xs_dict_get(user->config, "muted_words"),
2664                              xs_dict_get(msg, "content"))) != NULL) {
2665        snac_debug(user, 1, xs_fmt("word %s muted by user preferences: %s", v, id));
2666        snac_content = xs_html_tag("details",
2667            xs_html_tag("summary",
2668                xs_html_text(L("Muted: ")),
2669                xs_html_text(v)));
2670    }
2671    else {
2672        snac_content = xs_html_tag("div", NULL);
2673    }
2674
2675    xs_html_add(snac_content_wrap,
2676        snac_content);
2677
2678    /* add all emoji reacts */
2679    int is_emoji = 0;
2680    if (!xs_is_true(xs_dict_get(srv_config, "disable_emojireact"))) {
2681        int c = 0;
2682        const xs_dict *k;
2683        xs *ls = xs_list_new();
2684        xs *sfrl = xs_dict_new();
2685        xs *rl = object_get_emoji_reacts(id);
2686
2687        while (xs_list_next(rl, &v, &c)) {
2688            xs *m = NULL;
2689            if (valid_status(object_get_by_md5(v, &m))) {
2690                const char *content = xs_dict_get(m, "content");
2691                const char *actor = xs_dict_get(m, "actor");
2692                const xs_list *contentl = xs_dict_get(sfrl, content);
2693
2694                if ((user && is_muted(user, actor)) || is_instance_blocked(actor))
2695                    continue;
2696
2697                xs *actors = xs_list_new();
2698                actors = xs_list_append(actors, actor);
2699                char me = actor && user && strcmp(actor, user->actor) == 0;
2700                int count = 1;
2701
2702                if (contentl) {
2703                    count = atoi(xs_list_get(contentl, 0)) + 1;
2704                    const xs_list *actorsc = xs_list_get(contentl, 1);
2705                    if (strncmp(xs_list_get(contentl, 2), "1", 1) == 0)
2706                        me = 1;
2707
2708                    if (xs_list_in(actorsc, actor) != -1) {
2709                        xs_free(actors);
2710                        actors = xs_dup(actorsc);
2711                    }
2712                    else
2713                        actors = xs_list_cat(actors, actorsc);
2714                }
2715
2716                xs *fl = xs_list_new();
2717                xs *c1 = xs_fmt("%d", count);
2718                xs *c2 = xs_fmt("%d", me);
2719                fl = xs_list_append(fl, c1, actors, c2);
2720                sfrl = xs_dict_append(sfrl, content, fl);
2721            }
2722        }
2723
2724        c = 0;
2725
2726        while (xs_list_next(rl, &k, &c)) {
2727            xs *m = NULL;
2728            if (valid_status(object_get_by_md5(k, &m))) {
2729                const xs_dict *tag = xs_dict_get(m, "tag");
2730                const xs_dict *ide = xs_dict_get(m, "id");
2731
2732                const char *content = xs_dict_get(m, "content");
2733                const char *shortname;
2734                shortname = xs_dict_get(m, "content");
2735
2736                const xs_list *items = xs_dict_get(sfrl, content);
2737
2738                if (!xs_is_null(items)) {
2739                    const char *nb = xs_list_get(items, 0);
2740                    const xs_list *actors = xs_list_get(items, 1);
2741                    const char me = *xs_list_get(items, 2) == '1';
2742
2743                    is_emoji = 1;
2744
2745                    xs *al = xs_join(actors, ",\n\t");
2746                    xs *act = atoi(nb) > 1 ?
2747                        xs_fmt("%d different actors \n\t%s", atoi(nb), al) :
2748                        xs_dup(xs_dict_get(m, "actor"));
2749
2750                    xs *class = xs_list_new();
2751                    class = xs_list_append(class, "snac-reaction");
2752
2753                    xs_html *ret = NULL;
2754                    if (tag && shortname) {
2755                        xs *cl = xs_list_new();
2756                        cl = xs_list_append(cl, "snac-reaction-image");
2757                        xs *emoji = _replace_shortnames(xs_dup(shortname), tag, 2, proxy, cl, act);
2758
2759                        emoji = xs_strip_chars_i(emoji, ":");
2760
2761                        if (me)
2762                            class = xs_list_append(class, "snac-reacted");
2763
2764                        xs *l1 = xs_join(class, " ");
2765                        ret = xs_html_tag("button",
2766                                xs_html_attr("type", "submit"),
2767                                xs_html_attr("name", "action"),
2768                                xs_html_attr("value", me ? L("EmojiReact") : L("EmojiUnreact")),
2769                                xs_html_raw(emoji),
2770                                xs_html_tag("span",
2771                                    xs_html_raw(nb),
2772                                    xs_html_attr("style", "padding-left: 5px;")),
2773                                xs_html_attr("title", act),
2774                                xs_html_attr("class", l1));
2775
2776                        if (!(ide && xs_startswith(ide, srv_baseurl)))
2777                            xs_html_add(ret, xs_html_attr("disabled", "true"));
2778                    }
2779                    else if (shortname) {
2780                        xs *sn = xs_dup(shortname);
2781                        const char *sna = sn;
2782                        unsigned int utf = xs_utf8_dec((const char **)&sna);
2783
2784                        if (xs_is_emoji(utf)) {
2785                            const char *style = "font-size: large;";
2786                            if (me)
2787                                class = xs_list_append(class, "snac-reacted");
2788                            xs *l1 = xs_join(class, " ");
2789                            xs *s1 = xs_fmt("&#%d", utf);
2790                            ret = xs_html_tag("button",
2791                                    xs_html_attr("type", "submit"),
2792                                    xs_html_attr("name", "action"),
2793                                    xs_html_attr("value", me ? L("EmojiUnreact") : L("EmojiReact")),
2794                                    xs_html_raw(s1),
2795                                    xs_html_tag("span",
2796                                        xs_html_raw(nb),
2797                                        xs_html_attr("style", "font-size: initial; padding-left: 5px;")),
2798                                    xs_html_attr("title", act),
2799                                    xs_html_attr("class", l1),
2800                                    xs_html_attr("style", style));
2801                        }
2802                    }
2803                    if (ret) {
2804                        xs *s1;
2805                        if (user) {
2806                            xs *action = xs_fmt("%s/admin/action", user->actor);
2807                            xs *form_id = xs_fmt("%s_reply_form", md5);
2808
2809                            xs_html *form =
2810                                xs_html_tag("form",
2811                                xs_html_attr("autocomplete", "off"),
2812                                xs_html_attr("method",       "post"),
2813                                xs_html_attr("action",       action),
2814                                xs_html_attr("enctype",      "multipart/form-data"),
2815                                xs_html_attr("style",      "display: inline-flex;"
2816                                    "vertical-align: middle;"),
2817                                xs_html_attr("id",           form_id),
2818                                xs_html_sctag("input",
2819                                    xs_html_attr("type",  "hidden"),
2820                                    xs_html_attr("name",  "id"),
2821                                    xs_html_attr("value", id)),
2822                                xs_html_sctag("input",
2823                                    xs_html_attr("type",  "hidden"),
2824                                    xs_html_attr("name",  "eid"),
2825                                    xs_html_attr("value", shortname)),
2826                                ret);
2827                            s1 = xs_html_render(form);
2828                        }
2829                        else
2830                            s1 = xs_html_render(ret);
2831
2832                        ls = xs_list_append(ls, s1);
2833                        sfrl = xs_dict_del(sfrl, content);
2834                    }
2835                }
2836            }
2837        }
2838
2839        c = 0;
2840
2841        xs_html *emoji_div;
2842        if (xs_list_len(ls) > 0) {
2843                emoji_div = xs_html_tag("div", xs_html_text(L("Emoji reactions: ")),
2844                        xs_html_attr("class", "snac-reaction-div"));
2845
2846            while (ls != NULL && xs_list_next(ls, &k, &c))
2847                xs_html_add(emoji_div, xs_html_raw(k));
2848
2849            xs_html_add(snac_content_wrap, emoji_div);
2850        }
2851
2852    }
2853
2854    {
2855        /** build the content string **/
2856        const char *content = xs_dict_get(msg, "content");
2857
2858        if (xs_type(content) != XSTYPE_STRING) {
2859            if (!xs_is_null(content))
2860                srv_archive_error("unexpected_content_xstype",
2861                    "content field type", xs_stock(XSTYPE_DICT), msg);
2862
2863            content = "";
2864        }
2865
2866        /* skip ugly line breaks at the beginning */
2867        while (xs_startswith(content, "<br>"))
2868            content += 4;
2869
2870        xs *c = sanitize(content);
2871
2872        /* do some tweaks to the content */
2873        c = xs_replace_i(c, "\r", "");
2874
2875        while (xs_endswith(c, "<br><br>"))
2876            c = xs_crop_i(c, 0, -4);
2877
2878        c = xs_replace_i(c, "<br><br>", "<p>");
2879
2880        if (is_emoji == 0)
2881            c = xs_str_cat(c, "<p>");
2882
2883        /* replace the :shortnames: */
2884        c = replace_shortnames(c, xs_dict_get(msg, "tag"), 2, proxy);
2885
2886        /* Peertube videos content is in markdown */
2887        const char *mtype = xs_dict_get(msg, "mediaType");
2888        if (xs_type(mtype) == XSTYPE_STRING && strcmp(mtype, "text/markdown") == 0) {
2889            /* a full conversion could be better */
2890            c = xs_replace_i(c, "\r", "");
2891            c = xs_replace_i(c, "\n", "<br>");
2892        }
2893
2894        /* c contains sanitized HTML */
2895        xs_html_add(snac_content,
2896            xs_html_raw(c));
2897
2898        /* quoted post */
2899        const char *quoted_id = xs_or(xs_dict_get(msg, "quoteUri"), xs_dict_get(msg, "quoteUrl"));
2900
2901        if (level < 3 && xs_is_string(quoted_id) && xs_match(quoted_id, "https://*|http://*")) { /** **/
2902            xs *quoted_post = NULL;
2903
2904            if (valid_status(object_get(quoted_id, &quoted_post))) {
2905                xs *md5 = xs_md5_hex(quoted_id, strlen(quoted_id));
2906
2907                xs_html_add(snac_content,
2908                    xs_html_tag("blockquote",
2909                        xs_html_attr("class", "snac-quoted-post"),
2910                        html_entry(user, quoted_post, 1, level + 1, md5, 1, tb_friendly)));
2911            }
2912            else
2913            if (user)
2914                enqueue_object_request(user, quoted_id, 0);
2915        }
2916    }
2917
2918    if (strcmp(type, "Question") == 0) { /** question content **/
2919        const xs_list *oo = xs_dict_get(msg, "oneOf");
2920        const xs_list *ao = xs_dict_get(msg, "anyOf");
2921        const xs_list *p;
2922        const xs_dict *v;
2923        int closed = 0;
2924        const char *f_closed = NULL;
2925
2926        xs_html *poll = xs_html_tag("div", NULL);
2927
2928        if (read_only)
2929            closed = 1; /* non-identified page; show as closed */
2930        else
2931        if (user && is_msg_mine(user, id))
2932            closed = 1; /* we questioned; closed for us */
2933        else
2934        if (user && was_question_voted(user, id))
2935            closed = 1; /* we already voted; closed for us */
2936
2937        if ((f_closed = xs_dict_get(msg, "closed")) != NULL) {
2938            /* it has a closed date... but is it in the past? */
2939            time_t t0 = time(NULL);
2940            time_t t1 = xs_parse_iso_date(f_closed, 0);
2941
2942            if (t1 < t0)
2943                closed = 2;
2944        }
2945
2946        /* get the appropriate list of options */
2947        p = oo != NULL ? oo : ao;
2948
2949        if (closed || user == NULL) {
2950            /* closed poll */
2951            xs_html *poll_result = xs_html_tag("table",
2952                xs_html_attr("class", "snac-poll-result"));
2953            int c = 0;
2954
2955            while (xs_list_next(p, &v, &c)) {
2956                const char *name       = xs_dict_get(v, "name");
2957                const xs_dict *replies = xs_dict_get(v, "replies");
2958
2959                if (xs_is_string(name) && xs_is_dict(replies)) {
2960                    const char *ti = xs_number_str(xs_dict_get(replies, "totalItems"));
2961
2962                    if (xs_is_string(ti))
2963                        xs_html_add(poll_result,
2964                            xs_html_tag("tr",
2965                                xs_html_tag("td",
2966                                    xs_html_text(name),
2967                                    xs_html_text(":")),
2968                                xs_html_tag("td",
2969                                    xs_html_text(ti))));
2970                }
2971            }
2972
2973            xs_html_add(poll,
2974                poll_result);
2975        }
2976        else {
2977            /* poll still active */
2978            xs *vote_action = xs_fmt("%s/admin/vote", user->actor);
2979            xs_html *form;
2980            xs_html *poll_form = xs_html_tag("div",
2981                xs_html_attr("class", "snac-poll-form"),
2982                form = xs_html_tag("form",
2983                    xs_html_attr("autocomplete", "off"),
2984                    xs_html_attr("method", "post"),
2985                    xs_html_attr("action", vote_action),
2986                    xs_html_sctag("input",
2987                        xs_html_attr("type", "hidden"),
2988                        xs_html_attr("name", "actor"),
2989                        xs_html_attr("value", actor)),
2990                    xs_html_sctag("input",
2991                        xs_html_attr("type", "hidden"),
2992                        xs_html_attr("name", "irt"),
2993                        xs_html_attr("value", id))));
2994
2995            int c = 0;
2996            while (xs_list_next(p, &v, &c)) {
2997                const char *name = xs_dict_get(v, "name");
2998                const xs_dict *replies = xs_dict_get(v, "replies");
2999
3000                if (name) {
3001                    char *ti = (char *)xs_number_str(xs_dict_get(replies, "totalItems"));
3002
3003                    xs_html *btn = xs_html_sctag("input",
3004                            xs_html_attr("id", name),
3005                            xs_html_attr("value", name),
3006                            xs_html_attr("name", "question"));
3007
3008                    if (!xs_is_null(oo)) {
3009                        xs_html_add(btn,
3010                            xs_html_attr("type", "radio"),
3011                            xs_html_attr("required", "required"));
3012                    }
3013                    else
3014                        xs_html_add(btn,
3015                            xs_html_attr("type", "checkbox"));
3016
3017                    xs_html_add(form,
3018                        btn,
3019                        xs_html_text(" "),
3020                        xs_html_tag("span",
3021                            xs_html_attr("title", ti),
3022                            xs_html_text(name)),
3023                        xs_html_sctag("br", NULL));
3024                }
3025            }
3026
3027            xs_html_add(form,
3028                xs_html_tag("p", NULL),
3029                xs_html_sctag("input",
3030                    xs_html_attr("type", "submit"),
3031                    xs_html_attr("class", "button"),
3032                    xs_html_attr("value", L("Vote"))));
3033
3034            xs_html_add(poll,
3035                poll_form);
3036        }
3037
3038        /* if it's *really* closed, say it */
3039        if (closed == 2) {
3040            xs_html_add(poll,
3041                xs_html_tag("p",
3042                    xs_html_text(L("Closed"))));
3043        }
3044        else {
3045            /* show when the poll closes */
3046            const char *end_time = xs_dict_get(msg, "endTime");
3047
3048            /* Pleroma does not have an endTime field;
3049               it has a closed time in the future */
3050            if (xs_is_null(end_time))
3051                end_time = xs_dict_get(msg, "closed");
3052
3053            if (!xs_is_null(end_time)) {
3054                time_t t0 = time(NULL);
3055                time_t t1 = xs_parse_iso_date(end_time, 0);
3056
3057                if (t1 > 0 && t1 > t0) {
3058                    time_t diff_time = t1 - t0;
3059                    xs *tf = xs_str_time_diff(diff_time);
3060                    char *p = tf;
3061
3062                    /* skip leading zeros */
3063                    for (; *p == '0' || *p == ':'; p++);
3064
3065                    xs_html_add(poll,
3066                        xs_html_tag("p",
3067                            xs_html_text(L("Closes in")),
3068                            xs_html_text(" "),
3069                            xs_html_text(p)));
3070                }
3071            }
3072        }
3073
3074        xs_html_add(snac_content,
3075            poll);
3076    }
3077
3078    /** attachments **/
3079    xs *attach = get_attachments(msg);
3080
3081    {
3082        /* make custom css for attachments easier */
3083        xs_html *content_attachments = xs_html_tag("div",
3084            xs_html_attr("class", "snac-content-attachments"));
3085
3086        xs_html_add(snac_content,
3087            content_attachments);
3088
3089        const char *content = xs_dict_get(msg, "content");
3090
3091        int c = 0;
3092        const xs_dict *a;
3093        while (xs_list_next(attach, &a, &c)) {
3094            const char *type = xs_dict_get(a, "type");
3095            const char *o_href = xs_dict_get(a, "href");
3096            const char *name = xs_dict_get(a, "name");
3097
3098            if (!xs_is_string(type) || !xs_is_string(o_href))
3099                continue;
3100
3101            /* if this URL is already in the post content, skip */
3102            if (content && xs_str_in(content, o_href) != -1)
3103                continue;
3104
3105            if (strcmp(type, "image/svg+xml") == 0 && !xs_is_true(xs_dict_get(srv_config, "enable_svg")))
3106                continue;
3107
3108            /* do this attachment include an icon? */
3109            const xs_dict *icon = xs_dict_get(a, "icon");
3110            if (xs_type(icon) == XSTYPE_DICT) {
3111                const char *icon_mtype = xs_dict_get(icon, "mediaType");
3112                const char *icon_url   = xs_dict_get(icon, "url");
3113
3114                if (icon_mtype && icon_url && xs_startswith(icon_mtype, "image/")) {
3115                    xs_html_add(content_attachments,
3116                        xs_html_tag("a",
3117                            xs_html_attr("href", icon_url),
3118                            xs_html_attr("target", "_blank"),
3119                            xs_html_sctag("img",
3120                                xs_html_attr("loading", "lazy"),
3121                                xs_html_attr("src", icon_url))));
3122                }
3123            }
3124
3125            xs *href = make_url(o_href, proxy, 0);
3126
3127            if (xs_startswith(type, "image/") || strcmp(type, "Image") == 0) {
3128                xs_html_add(content_attachments,
3129                    xs_html_tag("a",
3130                        xs_html_attr("href", href),
3131                        xs_html_attr("target", "_blank"),
3132                        xs_html_sctag("img",
3133                            xs_html_attr("loading", "lazy"),
3134                            xs_html_attr("src", href),
3135                            xs_html_attr("alt", name),
3136                            xs_html_attr("title", name))));
3137            }
3138            else
3139            if (xs_startswith(type, "video/") || strcmp(type, "Video") == 0) {
3140                xs_html_add(content_attachments,
3141                    xs_html_tag("video",
3142                        xs_html_attr("preload", "none"),
3143                        xs_html_attr("style", "width: 100%"),
3144                        xs_html_attr("class", "snac-embedded-video"),
3145                        xs_html_attr("controls", NULL),
3146                        xs_html_attr("src", href),
3147                        xs_html_text(L("Video")),
3148                        xs_html_text(": "),
3149                        xs_html_tag("a",
3150                            xs_html_attr("href", href),
3151                            xs_html_text(name))));
3152            }
3153            else
3154            if (xs_startswith(type, "audio/")) {
3155                xs_html_add(content_attachments,
3156                    xs_html_tag("audio",
3157                        xs_html_attr("preload", "none"),
3158                        xs_html_attr("style", "width: 100%"),
3159                        xs_html_attr("class", "snac-embedded-audio"),
3160                        xs_html_attr("controls", NULL),
3161                        xs_html_attr("src", href),
3162                        xs_html_text(L("Audio")),
3163                        xs_html_text(": "),
3164                        xs_html_tag("a",
3165                            xs_html_attr("href", href),
3166                            xs_html_text(name))));
3167            }
3168            else
3169            if (strcmp(type, "Link") == 0) {
3170                xs_html_add(content_attachments,
3171                    xs_html_tag("p",
3172                        xs_html_tag("a",
3173                            xs_html_attr("href", o_href),
3174                            xs_html_text(o_href))));
3175
3176                /* do not generate an Alt... */
3177                name = NULL;
3178            }
3179            else {
3180                xs *d_href = xs_dup(o_href);
3181                if (strlen(d_href) > 64) {
3182                    d_href[64] = '\0';
3183                    d_href = xs_str_cat(d_href, "...");
3184                }
3185
3186                xs_html_add(content_attachments,
3187                    xs_html_tag("p",
3188                        xs_html_tag("a",
3189                            xs_html_attr("href", o_href),
3190                            xs_html_text(L("Attachment")),
3191                            xs_html_text(": "),
3192                            xs_html_text(d_href))));
3193
3194                /* do not generate an Alt... */
3195                name = NULL;
3196            }
3197
3198            if (name != NULL && *name) {
3199                xs_html_add(content_attachments,
3200                    xs_html_tag("p",
3201                        xs_html_attr("class", "snac-alt-text"),
3202                        xs_html_tag("details",
3203                            xs_html_tag("summary",
3204                                xs_html_text(L("Alt..."))),
3205                            xs_html_text(name))));
3206            }
3207        }
3208    }
3209
3210    /* has this message an audience (i.e., comes from a channel or community)? */
3211    const char *audience = xs_dict_get(msg, "audience");
3212    if (strcmp(type, "Page") == 0 && !xs_is_null(audience)) {
3213        xs_html *au_tag = xs_html_tag("p",
3214            xs_html_text("("),
3215            xs_html_tag("a",
3216                xs_html_attr("href", audience),
3217                xs_html_attr("title", L("Source channel or community")),
3218                xs_html_text(audience)),
3219            xs_html_text(")"));
3220
3221        xs_html_add(snac_content_wrap,
3222            au_tag);
3223    }
3224
3225    /* does it have a location? */
3226    const xs_dict *location = xs_dict_get(msg, "location");
3227    if (xs_type(location) == XSTYPE_DICT) {
3228        const xs_number *latitude = xs_dict_get(location, "latitude");
3229        const xs_number *longitude = xs_dict_get(location, "longitude");
3230        const char *name = xs_dict_get(location, "name");
3231        const char *address = xs_dict_get(location, "address");
3232        xs *label_list = xs_list_new();
3233
3234        if (xs_type(name) == XSTYPE_STRING)
3235            label_list = xs_list_append(label_list, name);
3236        if (xs_type(address) == XSTYPE_STRING)
3237            label_list = xs_list_append(label_list, address);
3238
3239        if (xs_list_len(label_list)) {
3240            const char *url = xs_dict_get(location, "url");
3241            xs *label = xs_join(label_list, ", ");
3242
3243            if (xs_type(url) == XSTYPE_STRING) {
3244                xs_html_add(snac_content_wrap,
3245                    xs_html_tag("p",
3246                        xs_html_text(L("Location: ")),
3247                        xs_html_tag("a",
3248                            xs_html_attr("href", url),
3249                            xs_html_attr("target", "_blank"),
3250                            xs_html_text(label))));
3251            }
3252            else
3253            if (!xs_is_null(latitude) && !xs_is_null(longitude)) {
3254                xs *url = xs_fmt("https://openstreetmap.org/search/?query=%s,%s",
3255                    xs_or(xs_number_str(latitude), latitude), xs_or(xs_number_str(longitude), longitude));
3256
3257                xs_html_add(snac_content_wrap,
3258                    xs_html_tag("p",
3259                        xs_html_text(L("Location: ")),
3260                        xs_html_tag("a",
3261                            xs_html_attr("href", url),
3262                            xs_html_attr("target", "_blank"),
3263                            xs_html_text(label))));
3264            }
3265            else
3266                xs_html_add(snac_content_wrap,
3267                    xs_html_tag("p",
3268                        xs_html_text(L("Location: ")),
3269                        xs_html_text(label)));
3270        }
3271    }
3272
3273    if (strcmp(type, "Event") == 0) { /** Event start and end times **/
3274        const char *s_time = xs_dict_get(msg, "startTime");
3275
3276        if (xs_is_string(s_time) && strlen(s_time) > 20) {
3277            const char *e_time = xs_dict_get(msg, "endTime");
3278            const char *tz     = xs_dict_get(msg, "timezone");
3279
3280            xs *s = xs_replace_i(xs_dup(s_time), "T", " ");
3281            xs *e = NULL;
3282
3283            if (xs_is_string(e_time) && strlen(e_time) > 20)
3284                e = xs_replace_i(xs_dup(e_time), "T", " ");
3285
3286            /* if the event has a timezone, crop the offsets */
3287            if (xs_is_string(tz)) {
3288                s = xs_crop_i(s, 0, 19);
3289
3290                if (e)
3291                    e = xs_crop_i(e, 0, 19);
3292            }
3293            else
3294                tz = "";
3295
3296            /* if start and end share the same day, crop it from the end */
3297            if (e && memcmp(s, e, 11) == 0)
3298                e = xs_crop_i(e, 11, 0);
3299
3300            if (e)
3301                s = xs_str_cat(s, " / ", e);
3302
3303            if (*tz)
3304                s = xs_str_cat(s, " (", tz, ")");
3305
3306            /* replace ugly decimals */
3307            s = xs_replace_i(s, ".000", "");
3308
3309            xs_html_add(snac_content_wrap,
3310                xs_html_tag("p",
3311                    xs_html_text(L("Time: ")),
3312                    xs_html_text(s)));
3313        }
3314    }
3315
3316    /* show all hashtags that has not been shown previously in the content */
3317    const xs_list *tags = xs_dict_get(msg, "tag");
3318    const char *o_content = xs_dict_get_def(msg, "content", "");
3319
3320    if (xs_is_string(o_content) && xs_is_list(tags) && xs_list_len(tags)) {
3321        xs *content = xs_utf8_to_lower(o_content);
3322        const xs_dict *tag;
3323
3324        xs_html *add_hashtags = xs_html_tag("ul",
3325            xs_html_attr("class", "snac-more-hashtags"));
3326
3327        xs_list_foreach(tags, tag) {
3328            const char *type = xs_dict_get(tag, "type");
3329
3330            if (xs_is_string(type) && strcmp(type, "Hashtag") == 0) {
3331                const char *o_href = xs_dict_get(tag, "href");
3332                const char *name   = xs_dict_get(tag, "name");
3333
3334                if (xs_is_string(o_href) && xs_is_string(name)) {
3335                    xs *href = xs_utf8_to_lower(o_href);
3336
3337                    if (xs_str_in(content, href) == -1 && xs_str_in(content, name) == -1) {
3338                        /* not in the content: add here */
3339                        xs_html_add(add_hashtags,
3340                            xs_html_tag("li",
3341                                xs_html_tag("a",
3342                                    xs_html_attr("href", href),
3343                                    xs_html_text(name),
3344                                    xs_html_text(" "))));
3345                    }
3346                }
3347            }
3348        }
3349
3350        xs_html_add(snac_content_wrap,
3351            add_hashtags);
3352    }
3353
3354    /** controls **/
3355
3356    if (!read_only && user) {
3357        xs_html_add(entry,
3358            html_entry_controls(user, actor, msg, md5, tb_friendly));
3359    }
3360
3361    /** children **/
3362    if (!hide_children) {
3363        xs *children = object_children(id);
3364        int left     = xs_list_len(children);
3365
3366        if (left) {
3367            xs_html *ch_details = xs_html_tag("details",
3368                xs_html_attr(collapse_threads ? "" : "open", NULL),
3369                xs_html_tag("summary",
3370                    xs_html_text("...")));
3371
3372            xs_html_add(entry,
3373                ch_details);
3374
3375            xs_html *fch_container = xs_html_tag("div",
3376                xs_html_attr("class", "snac-thread-cont"));
3377
3378            xs_html_add(ch_details,
3379                fch_container);
3380
3381            xs_html *ch_container = xs_html_tag("div",
3382                xs_html_attr("class", level < 4 ? "snac-children" : "snac-children-too-deep"));
3383
3384            xs_html_add(ch_details,
3385                ch_container);
3386
3387            xs_html *ch_older = NULL;
3388            if (left > 3) {
3389                xs_html_add(ch_container,
3390                    ch_older = xs_html_tag("details",
3391                        xs_html_tag("summary",
3392                            xs_html_text(L("Older...")))));
3393            }
3394
3395            int ctxt = 0;
3396            const char *cmd5;
3397            int cnt = 0;
3398            int o_cnt = 0;
3399            int f_cnt = 0;
3400
3401            /* get the first child */
3402            xs_list_next(children, &cmd5, &ctxt);
3403            xs *f_chd = NULL;
3404
3405            if (user)
3406                timeline_get_by_md5(user, cmd5, &f_chd);
3407            else
3408                object_get_by_md5(cmd5, &f_chd);
3409
3410            if (f_chd != NULL && xs_is_null(xs_dict_get(f_chd, "name"))) {
3411                const char *p_author = get_atto(msg);
3412                const char *author   = get_atto(f_chd);
3413
3414                /* is the first child from the same author? */
3415                if (xs_is_string(p_author) && xs_is_string(author) && strcmp(p_author, author) == 0) {
3416                    /* then, don't add it to the children container,
3417                       so that it appears unindented just before the parent
3418                       like a fucking Twitter-like thread */
3419                    xs_html_add(fch_container,
3420                        html_entry(user, f_chd, read_only, level + 1, cmd5, hide_children, tb_friendly));
3421
3422                    cnt++;
3423                    f_cnt++;
3424                    left--;
3425                }
3426                else
3427                    ctxt = 0; /* restart from the beginning */
3428            }
3429
3430            while (xs_list_next(children, &cmd5, &ctxt)) {
3431                xs *chd = NULL;
3432
3433                if (user)
3434                    timeline_get_by_md5(user, cmd5, &chd);
3435                else
3436                    object_get_by_md5(cmd5, &chd);
3437
3438                if (chd != NULL) {
3439                    if (xs_is_null(xs_dict_get(chd, "name"))) {
3440                        xs_html *che = html_entry(user, chd, read_only,
3441                            level + 1, cmd5, hide_children, tb_friendly);
3442
3443                        if (che != NULL) {
3444                            if (left > 3) {
3445                                xs_html_add(ch_older,
3446                                    che);
3447
3448                                o_cnt++;
3449                            }
3450                            else
3451                                xs_html_add(ch_container,
3452                                    che);
3453
3454                            cnt++;
3455                        }
3456                    }
3457
3458                    left--;
3459                }
3460                else
3461                    srv_debug(2, xs_fmt("cannot read child %s", cmd5));
3462            }
3463
3464            /* if no children were finally added, hide the details */
3465            if (cnt == 0)
3466                xs_html_add(ch_details,
3467                    xs_html_attr("style", "display: none"));
3468
3469            if (o_cnt == 0 && ch_older)
3470                xs_html_add(ch_older,
3471                    xs_html_attr("style", "display: none"));
3472
3473            if (f_cnt == 0)
3474                xs_html_add(fch_container,
3475                    xs_html_attr("style", "display: none"));
3476        }
3477    }
3478
3479    /* add an invisible hr, to help differentiate between posts in text browsers */
3480    xs_html_add(entry_top,
3481        xs_html_sctag("hr",
3482            xs_html_attr("hidden", NULL)));
3483
3484    return entry_top;
3485}
3486
3487
3488xs_html *html_footer(const snac *user)
3489{
3490    return xs_html_tag("div",
3491        xs_html_attr("class", "snac-footer"),
3492        xs_html_tag("a",
3493            xs_html_attr("href", srv_baseurl),
3494            xs_html_text(L("about this site"))),
3495        xs_html_text(" - "),
3496        xs_html_text(L("powered by ")),
3497        xs_html_tag("a",
3498            xs_html_attr("href", WHAT_IS_SNAC_URL),
3499            xs_html_tag("abbr",
3500                xs_html_attr("title", "Social Networks Are Crap"),
3501                xs_html_text(USER_AGENT))));
3502}
3503
3504
3505xs_str *html_timeline(snac *user, const xs_list *list, int read_only,
3506                      int skip, int show, int show_more,
3507                      const char *title, const char *page,
3508                      int utl, const char *error, int terse, int tb_friendly)
3509/* returns the HTML for the timeline */
3510{
3511    const char *v;
3512    double t = ftime();
3513    int hide_children = xs_is_true(xs_dict_get(srv_config, "strict_public_timelines")) && read_only;
3514
3515    xs *desc = NULL;
3516    xs *alternate = NULL;
3517
3518    if (xs_list_len(list) == 1) {
3519        /* only one element? pick the description from the source */
3520        const char *id = xs_list_get(list, 0);
3521        xs *d = NULL;
3522        object_get_by_md5(id, &d);
3523        const char *sc = xs_dict_get(d, "sourceContent");
3524        if (d && sc != NULL)
3525            desc = xs_dup(sc);
3526
3527        alternate = xs_dup(xs_dict_get(d, "id"));
3528    }
3529
3530    xs_html *head;
3531    xs_html *body;
3532
3533    if (user) {
3534        head = html_user_head(user, desc, alternate);
3535
3536        if (terse)
3537            body = xs_html_tag("body", NULL);
3538        else
3539            body = html_user_body(user, read_only);
3540    }
3541    else {
3542        head = html_instance_head();
3543        body = html_instance_body();
3544    }
3545
3546    xs_html *html = xs_html_tag("html",
3547        head,
3548        body);
3549
3550    if (user && !read_only)
3551        xs_html_add(body,
3552            html_top_controls(user, tb_friendly));
3553
3554    if (error != NULL) {
3555        xs_html_add(body,
3556            xs_html_tag("dialog",
3557                xs_html_attr("open", NULL),
3558                xs_html_tag("p",
3559                    xs_html_text(error)),
3560                xs_html_tag("form",
3561                    xs_html_attr("method", "dialog"),
3562                    xs_html_sctag("input",
3563                        xs_html_attr("type", "submit"),
3564                        xs_html_attr("value", L("Dismiss"))))));
3565    }
3566
3567    /* show links to the available lists */
3568    if (user && !read_only) {
3569        xs_html *lol = xs_html_tag("ul",
3570            xs_html_attr("class", "snac-list-of-lists"));
3571        xs_html_add(body, lol);
3572
3573        xs *lists = list_maint(user, NULL, OP_LIST); /* get list of lists */
3574
3575        const char *v;
3576
3577        xs_list_foreach(lists, v) {
3578            const char *lname = xs_list_get(v, 1);
3579            xs *url = xs_fmt("%s/list/%s", user->actor, xs_list_get(v, 0));
3580            xs *ttl = xs_fmt(L("Timeline for list '%s'"), lname);
3581
3582            xs_html_add(lol,
3583                xs_html_tag("li",
3584                    xs_html_tag("a",
3585                        xs_html_attr("href", url),
3586                        xs_html_attr("class", "snac-list-link"),
3587                        xs_html_attr("title", ttl),
3588                        xs_html_text(lname))));
3589        }
3590
3591        {
3592            /* show the list of pinned posts */
3593            xs *url = xs_fmt("%s/pinned", user->actor);
3594            xs_html_add(lol,
3595                xs_html_tag("li",
3596                    xs_html_tag("a",
3597                        xs_html_attr("href", url),
3598                        xs_html_attr("class", "snac-list-link"),
3599                        xs_html_attr("title", L("Pinned posts")),
3600                        xs_html_text(L("pinned")))));
3601        }
3602
3603        {
3604            /* show the list of bookmarked posts */
3605            xs *url = xs_fmt("%s/admirations", user->actor);
3606            xs_html_add(lol,
3607                xs_html_tag("li",
3608                    xs_html_tag("a",
3609                        xs_html_attr("href", url),
3610                        xs_html_attr("class", "snac-list-link"),
3611                        xs_html_attr("title", L("Liked, boosted or reacted posts")),
3612                        xs_html_text(L("admirations")))));
3613        }
3614
3615        {
3616            /* show the list of bookmarked posts */
3617            xs *url = xs_fmt("%s/bookmarks", user->actor);
3618            xs_html_add(lol,
3619                xs_html_tag("li",
3620                    xs_html_tag("a",
3621                        xs_html_attr("href", url),
3622                        xs_html_attr("class", "snac-list-link"),
3623                        xs_html_attr("title", L("Bookmarked posts")),
3624                        xs_html_text(L("bookmarks")))));
3625        }
3626
3627        {
3628            /* show the list of drafts */
3629            xs *url = xs_fmt("%s/drafts", user->actor);
3630            xs_html_add(lol,
3631                xs_html_tag("li",
3632                    xs_html_tag("a",
3633                        xs_html_attr("href", url),
3634                        xs_html_attr("class", "snac-list-link"),
3635                        xs_html_attr("title", L("Post drafts")),
3636                        xs_html_text(L("drafts")))));
3637        }
3638
3639        {
3640            /* show the list of scheduled posts */
3641            xs *url = xs_fmt("%s/sched", user->actor);
3642            xs_html_add(lol,
3643                xs_html_tag("li",
3644                    xs_html_tag("a",
3645                        xs_html_attr("href", url),
3646                        xs_html_attr("class", "snac-list-link"),
3647                        xs_html_attr("title", L("Scheduled posts")),
3648                        xs_html_text(L("scheduled posts")))));
3649        }
3650
3651        /* the list of followed hashtags */
3652        const char *followed_hashtags = xs_dict_get(user->config, "followed_hashtags");
3653
3654        if (xs_is_list(followed_hashtags) && xs_list_len(followed_hashtags)) {
3655            xs_html *loht = xs_html_tag("ul",
3656                xs_html_attr("class", "snac-list-of-lists"));
3657            xs_html_add(body, loht);
3658
3659            const char *ht;
3660
3661            xs_list_foreach(followed_hashtags, ht) {
3662                xs *url = NULL;
3663
3664                if (!xs_startswith(ht, "https:/""/"))
3665                    url = xs_fmt("%s/admin?q=%s", user->actor, ht);
3666                else
3667                    url = xs_dup(ht);
3668
3669                url = xs_replace_i(url, "#", "%23");
3670
3671                xs_html_add(loht,
3672                    xs_html_tag("li",
3673                        xs_html_tag("a",
3674                            xs_html_attr("href", url),
3675                            xs_html_attr("class", "snac-list-link"),
3676                            xs_html_text(ht))));
3677            }
3678        }
3679    }
3680
3681    xs_html_add(body,
3682        xs_html_tag("a",
3683            xs_html_attr("name", "snac-posts")));
3684
3685    xs_html *posts = xs_html_tag("div",
3686        xs_html_attr("class", "snac-posts"));
3687
3688    if (title) {
3689        xs_html_add(posts,
3690            xs_html_tag("h2",
3691                xs_html_attr("class", "snac-header"),
3692                xs_html_text(title)));
3693    }
3694
3695    xs_html_add(body, posts);
3696
3697    int mark_shown = 0;
3698
3699    int show_unlisted = user ? xs_is_true(xs_dict_get(user->config, "show_unlisted")) : 0;
3700
3701    /* disable text-browser friendliness if it's just one post,
3702       to force the details/summary controls to appear */
3703    if (xs_list_len(list) == 1)
3704        tb_friendly = 0;
3705
3706    xs_list_foreach(list, v) {
3707        xs *msg = NULL;
3708        int status;
3709
3710        /* "already seen" mark? */
3711        if (strcmp(v, MD5_ALREADY_SEEN_MARK) == 0) {
3712            if (skip == 0 && !mark_shown) {
3713                xs *s = xs_fmt("%s/admin", user->actor);
3714
3715                xs_html_add(posts,
3716                    xs_html_tag("div",
3717                        xs_html_attr("class", "snac-no-more-unseen-posts"),
3718                        xs_html_text(L("No more unseen posts")),
3719                        xs_html_text(" - "),
3720                        xs_html_tag("a",
3721                            xs_html_attr("href", s),
3722                            xs_html_text(L("Back to top")))));
3723            }
3724
3725            mark_shown = 1;
3726
3727            continue;
3728        }
3729
3730        if (utl && user && !is_pinned_by_md5(user, v))
3731            status = timeline_get_by_md5(user, v, &msg);
3732        else
3733            status = object_get_by_md5(v, &msg);
3734
3735        if (!valid_status(status))
3736            continue;
3737
3738        /* if it's an instance page, discard messages from private users */
3739        if (user == NULL && is_msg_from_private_user(msg))
3740            continue;
3741
3742        const int scope = get_msg_visibility(msg);
3743        if (user != NULL && scope != SCOPE_PUBLIC && scope != SCOPE_UNLISTED && !is_msg_mine(user, xs_dict_get(msg, "id"))) {
3744            /* is this message a non-public reply? */
3745            const char *irt = get_in_reply_to(msg);
3746
3747            /* is it a reply to something not in the storage? */
3748            if (!xs_is_null(irt) && !object_here(irt)) {
3749                /* is it for me? */
3750                const xs_list *to = xs_dict_get_def(msg, "to", xs_stock(XSTYPE_LIST));
3751                const xs_list *cc = xs_dict_get_def(msg, "cc", xs_stock(XSTYPE_LIST));
3752
3753                if (xs_list_in(to, user->actor) == -1 && xs_list_in(cc, user->actor) == -1) {
3754                    snac_debug(user, 1, xs_fmt("skipping non-public reply to an unknown post %s", v));
3755                    continue;
3756                }
3757            }
3758        }
3759        /* hide non-public posts from /instance view */
3760        if (page != NULL && strcmp(page, "/instance") == 0 && scope != SCOPE_PUBLIC)
3761            continue;
3762
3763        /* hide non-public posts viewed from outside */
3764        if (read_only) {
3765            if (scope == SCOPE_MENTIONED || scope == SCOPE_FOLLOWERS)
3766                continue;
3767
3768            if (scope == SCOPE_UNLISTED && !show_unlisted)
3769                continue;
3770        }
3771
3772        xs_html *entry = html_entry(user, msg, read_only, 0, v, (user && !hide_children) ? 0 : 1, tb_friendly);
3773
3774        if (entry != NULL)
3775            xs_html_add(posts,
3776                entry);
3777    }
3778
3779    if (list && user && read_only) {
3780        /** history **/
3781        if (xs_type(xs_dict_get(srv_config, "disable_history")) != XSTYPE_TRUE && !terse) {
3782            xs_html *ul = xs_html_tag("ul", NULL);
3783
3784            xs_html *history = xs_html_tag("div",
3785                xs_html_attr("class", "snac-history"),
3786                xs_html_tag("p",
3787                    xs_html_attr("class", "snac-history-title"),
3788                    xs_html_text(L("History"))),
3789                    ul);
3790
3791            xs *list = history_list(user);
3792            xs_list *p = list;
3793            const char *v;
3794
3795            while (xs_list_iter(&p, &v)) {
3796                xs *fn  = xs_replace(v, ".html", "");
3797                xs *url = xs_fmt("%s/h/%s", user->actor, v);
3798
3799                xs_html_add(ul,
3800                    xs_html_tag("li",
3801                        xs_html_tag("a",
3802                            xs_html_attr("href", url),
3803                            xs_html_text(fn))));
3804            }
3805
3806            xs_html_add(body,
3807                history);
3808        }
3809    }
3810
3811    {
3812        xs *s1 = xs_fmt("\n<!-- %lf seconds -->\n", ftime() - t);
3813        xs_html_add(body,
3814            xs_html_raw(s1));
3815    }
3816
3817    if (show_more) {
3818        xs *m  = NULL;
3819        xs *ss = xs_fmt("skip=%d&show=%d", skip + show, show);
3820
3821        xs *url = xs_dup(user == NULL ? srv_baseurl : user->actor);
3822
3823        if (page != NULL)
3824            url = xs_str_cat(url, page);
3825
3826        if (xs_str_in(url, "?") != -1)
3827            m = xs_fmt("%s&%s", url, ss);
3828        else
3829            m = xs_fmt("%s?%s", url, ss);
3830
3831        xs_html *more_links = xs_html_tag("p",
3832            xs_html_tag("a",
3833                xs_html_attr("href", url),
3834                xs_html_attr("name", "snac-more"),
3835                xs_html_text(L("Back to top"))),
3836            xs_html_text(" - "),
3837            xs_html_tag("a",
3838                xs_html_attr("href", m),
3839                xs_html_attr("name", "snac-more"),
3840                xs_html_text(L("More..."))));
3841
3842        xs_html_add(body,
3843            more_links);
3844    }
3845
3846    xs_html_add(body,
3847        html_footer(user));
3848
3849    return xs_html_render_s(html, "<!DOCTYPE html>\n");
3850}
3851
3852
3853xs_html *html_people_list(snac *user, xs_list *list, const char *header, const char *t, const char *proxy, int count)
3854{
3855    xs_html *snac_posts;
3856    xs *header_cnt;
3857    if (count > -1)
3858        header_cnt = xs_fmt("%s - %d\n", header, count);
3859    else
3860        header_cnt = xs_fmt("%s\n", header);
3861
3862    xs_html *people = xs_html_tag("div",
3863        xs_html_tag("h2",
3864            xs_html_attr("class", "snac-header"),
3865            xs_html_text(header_cnt)),
3866        snac_posts = xs_html_tag("details",
3867                xs_html_attr("open", NULL),
3868                xs_html_tag("summary",
3869                    xs_html_text("..."))));
3870
3871    xs *redir = xs_fmt("%s/people", user->actor);
3872
3873    const char *actor_id;
3874
3875    xs_list_foreach(list, actor_id) {
3876        xs *md5 = xs_md5_hex(actor_id, strlen(actor_id));
3877        xs *actor = NULL;
3878
3879        if (valid_status(actor_get(actor_id, &actor))) {
3880            xs_html *snac_post = xs_html_tag("div",
3881                xs_html_attr("class", "snac-post"),
3882                xs_html_tag("a",
3883                    xs_html_attr("name", md5)),
3884                xs_html_tag("div",
3885                    xs_html_attr("class", "snac-post-header"),
3886                    html_actor_icon(user, actor, xs_dict_get(actor, "published"),
3887                                    NULL, NULL, -1, 0, proxy, NULL, NULL)));
3888
3889            /* content (user bio) */
3890            const char *c = xs_dict_get(actor, "summary");
3891            const xs_val *tag = xs_dict_get(actor, "tag");
3892
3893            if (!xs_is_null(c)) {
3894                xs *sc = sanitize(c);
3895                sc = replace_shortnames(sc, tag, 2, proxy);
3896
3897                xs_html *snac_content = xs_html_tag("div",
3898                    xs_html_attr("class", "snac-content"));
3899
3900                if (xs_startswith(sc, "<p>"))
3901                    xs_html_add(snac_content,
3902                        xs_html_raw(sc)); /* already sanitized */
3903                else
3904                    xs_html_add(snac_content,
3905                        xs_html_tag("p",
3906                            xs_html_raw(sc))); /* already sanitized */
3907
3908                xs_html_add(snac_post, snac_content);
3909            }
3910
3911            /* does it have account metrics in the object? */
3912            const xs_val *fwing = xs_dict_get(actor, "following_count");
3913            const xs_val *fwers = xs_dict_get(actor, "followers_count");
3914
3915            if (!xs_is_null(fwing) && !xs_is_null(fwers)) {
3916                int i_fwing = (int) xs_number_get_l(fwing);
3917                int i_fwers = (int) xs_number_get_l(fwers);
3918
3919                if (i_fwing || i_fwers) {
3920                    xs *s = xs_fmt(L("%d following, %d followers"), i_fwing, i_fwers);
3921
3922                    xs_html_add(snac_post,
3923                        xs_html_tag("div",
3924                            xs_html_tag("p",
3925                                xs_html_text(s))));
3926                }
3927            }
3928
3929            /* does it have a location? */
3930            const xs_dict *location = xs_dict_get(actor, "location");
3931
3932            if (xs_is_dict(location)) {
3933                const xs_val *latitude = xs_dict_get(location, "latitude");
3934                const xs_val *longitude = xs_dict_get(location, "longitude");
3935
3936                if (latitude && longitude) {
3937                    const char *name = xs_dict_get_def(location, "name", "Home");
3938                    const char *la = xs_is_string(latitude) ? latitude : xs_number_str(latitude);
3939                    const char *lo = xs_is_string(longitude) ? longitude : xs_number_str(longitude);
3940
3941                    if (xs_is_string(la) && xs_is_string(lo)) {
3942                        xs *sla = xs_utf8_crop_i(xs_dup(la), 0, 7);
3943                        xs *slo = xs_utf8_crop_i(xs_dup(lo), 0, 7);
3944
3945                        xs *label = xs_fmt("%s,%s", sla, slo);
3946                        xs *url   = xs_fmt("https://openstreetmap.org/search?query=%s,%s", la, lo);
3947
3948                        xs_html_add(snac_post,
3949                            xs_html_tag("p",
3950                                xs_html_text(name),
3951                                xs_html_text(": "),
3952                                xs_html_tag("a",
3953                                    xs_html_attr("href", url),
3954                                    xs_html_attr("target", "_blank"),
3955                                    xs_html_text(label))));
3956                    }
3957                }
3958            }
3959
3960            /* add user metadata */
3961            xs_html *snac_metadata = xs_html_tag("div",
3962                xs_html_attr("class", "snac-metadata"));
3963
3964            int count = 0;
3965            const xs_val *address = xs_dict_get(actor, "vcard:Address");
3966            if (xs_is_string(address)) {
3967                xs_html_add(snac_metadata,
3968                    xs_html_tag("span",
3969                        xs_html_attr("class", "snac-property-name"),
3970                        xs_html_raw("&#x1F4CD; Location")),
3971                    xs_html_text(":"),
3972                    xs_html_raw("&nbsp;"),
3973                    xs_html_tag("span",
3974                        xs_html_attr("class", "snac-property-value p-adr"),
3975                        xs_html_text(address)),
3976                    xs_html_sctag("br", NULL));
3977
3978                count++;
3979            }
3980
3981            const xs_val *birthday = xs_dict_get(actor, "vcard:bday");
3982            if (xs_is_string(birthday)) {
3983                xs_html_add(snac_metadata,
3984                    xs_html_tag("span",
3985                        xs_html_attr("class", "snac-property-name"),
3986                        xs_html_raw("&#x1F382; Birthday")),
3987                    xs_html_text(":"),
3988                    xs_html_raw("&nbsp;"),
3989                    xs_html_tag("time",
3990                        xs_html_attr("class", "snac-property-value dt-bday"),
3991                        xs_html_text(birthday)),
3992                    xs_html_sctag("br", NULL));
3993
3994                count++;
3995            }
3996
3997            const xs_list *attachment = xs_dict_get(actor, "attachment");
3998            if (count > 0 && xs_list_len(attachment) > 0) {
3999                xs_html_add(snac_metadata,
4000                    xs_html_sctag("hr",
4001                        xs_html_attr("class", "snac-property-divider")));
4002            }
4003
4004            const xs_val *v;
4005            xs_list_foreach(attachment, v) {
4006                const char *type  = xs_dict_get(v, "type");
4007                const char *name  = xs_dict_get(v, "name");
4008                const char *value = xs_dict_get(v, "value");
4009
4010                if (!xs_is_null(type) && !xs_is_null(name) &&
4011                    !xs_is_null(value) && strcmp(type, "PropertyValue") == 0) {
4012                    /* both the name and the value can contain emoji */
4013                    xs *nam = sanitize(name);
4014                    nam = replace_shortnames(nam, tag, 1, proxy);
4015
4016                    /* todo: sometimes the value is transmitted as markdown and not html ._. */
4017                    xs *val = sanitize(value);
4018                    val = replace_shortnames(val, tag, 1, proxy);
4019
4020                    /* delete <p> tags, because some software sends them */
4021                    val = xs_replace_i(val, "<p>", "");
4022                    val = xs_replace_i(val, "</p>", "");
4023
4024                    xs_html_add(snac_metadata,
4025                        xs_html_tag("span",
4026                            xs_html_attr("class", "snac-property-name"),
4027                            xs_html_raw(nam)),
4028                        xs_html_text(":"),
4029                        xs_html_raw("&nbsp;"),
4030                        xs_html_tag("span",
4031                            xs_html_attr("class", "snac-property-value"),
4032                            xs_html_raw(val)),
4033                        xs_html_sctag("br", NULL));
4034
4035                    count++;
4036                }
4037            }
4038
4039            if (count > 0) {
4040                xs_html_add(snac_post, snac_metadata);
4041            }
4042            else {
4043                /* free the html, by rendering it... */
4044                xs_free(xs_html_render(snac_metadata));
4045            }
4046
4047            /* buttons */
4048            xs *btn_form_action = xs_fmt("%s/admin/action", user->actor);
4049
4050            xs_html *snac_controls = xs_html_tag("div",
4051                xs_html_attr("class",   "snac-controls"));
4052
4053            xs_html *form = xs_html_tag("form",
4054                xs_html_attr("autocomplete",    "off"),
4055                xs_html_attr("method",          "post"),
4056                xs_html_attr("action",          btn_form_action),
4057                xs_html_sctag("input",
4058                    xs_html_attr("type",    "hidden"),
4059                    xs_html_attr("name",    "actor"),
4060                    xs_html_attr("value",   actor_id)),
4061                xs_html_sctag("input",
4062                    xs_html_attr("type",    "hidden"),
4063                    xs_html_attr("name",    "hard-redir"),
4064                    xs_html_attr("value",   redir)),
4065                xs_html_sctag("input",
4066                    xs_html_attr("type",    "hidden"),
4067                    xs_html_attr("name",    "actor-form"),
4068                    xs_html_attr("value",   "yes")));
4069
4070            xs_html_add(snac_controls, form);
4071
4072            if (following_check(user, actor_id)) {
4073                xs_html_add(form,
4074                    html_button("unfollow", L("Unfollow"),
4075                                L("Stop following this user's activity")));
4076
4077                if (is_limited(user, actor_id))
4078                    xs_html_add(form,
4079                        html_button("unlimit", L("Unlimit"),
4080                                L("Allow announces (boosts) from this user")));
4081                else
4082                    xs_html_add(form,
4083                        html_button("limit", L("Limit"),
4084                                L("Block announces (boosts) from this user")));
4085            }
4086            else {
4087                xs_html_add(form,
4088                    html_button("follow", L("Follow"),
4089                                L("Start following this user's activity")));
4090
4091                if (follower_check(user, actor_id))
4092                    xs_html_add(form,
4093                        html_button("delete", L("Delete"), L("Delete this user")));
4094            }
4095
4096            if (pending_check(user, actor_id)) {
4097                xs_html_add(form,
4098                    html_button("approve", L("Approve"),
4099                                L("Approve this follow request")));
4100
4101                xs_html_add(form,
4102                    html_button("discard", L("Discard"), L("Discard this follow request")));
4103            }
4104
4105            if (is_muted(user, actor_id))
4106                xs_html_add(form,
4107                    html_button("unmute", L("Unmute"),
4108                                L("Stop blocking activities from this user")));
4109            else
4110                xs_html_add(form,
4111                    html_button("mute", L("MUTE"),
4112                                L("Block any activity from this user")));
4113
4114            /* the post textarea */
4115            xs *dm_div_id  = xs_fmt("%s_%s_dm", md5, t);
4116            xs *dm_form_id = xs_fmt("%s_reply_form", md5);
4117
4118            xs_html_add(snac_controls,
4119                xs_html_tag("p", NULL),
4120                html_note(user, L("Direct Message..."),
4121                    dm_div_id, dm_form_id,
4122                    "", "",
4123                    NULL, actor_id,
4124                    xs_stock(XSTYPE_FALSE), "",
4125                    SCOPE_MENTIONED, NULL,
4126                    NULL, 0, NULL, NULL, 0, NULL, NULL),
4127                xs_html_tag("p", NULL));
4128
4129            xs_html_add(snac_post, snac_controls);
4130
4131            xs_html_add(snac_posts, snac_post);
4132        }
4133    }
4134
4135    return people;
4136}
4137
4138
4139xs_str *html_people(snac *user)
4140{
4141    const char *proxy = NULL;
4142
4143    if (xs_is_true(xs_dict_get(srv_config, "proxy_media")))
4144        proxy = user->actor;
4145
4146    xs *wing = following_list(user);
4147    xs *wers = follower_list(user);
4148    xs *pending = pending_list(user);
4149
4150    xs_html *lists = xs_html_tag("div",
4151        xs_html_attr("class", "snac-posts"));
4152
4153    if (xs_list_len(pending) || xs_is_true(xs_dict_get(user->config, "approve_followers"))) {
4154        xs_html_add(lists,
4155            html_people_list(user, pending, L("Pending follow confirmations"), "p", proxy, xs_list_len(pending)));
4156    }
4157
4158    xs_html_add(lists,
4159        html_people_list(user, wing, L("People you follow"), "i", proxy, following_list_len(user)),
4160        html_people_list(user, wers, L("People that follow you"), "e", proxy, xs_list_len(wers)));
4161
4162    xs_html *html = xs_html_tag("html",
4163        html_user_head(user, NULL, NULL),
4164        xs_html_add(html_user_body(user, 0),
4165            lists,
4166            html_footer(user)));
4167
4168    return xs_html_render_s(html, "<!DOCTYPE html>\n");
4169}
4170
4171/* Filter list to display only posts by actor.  We'll probably show
4172   fewer than show posts.  Should we try harder to find some?  */
4173xs_str *html_people_one(snac *user, const char *actor, const xs_list *list,
4174                        int skip, int show, int show_more, const char *page)
4175{
4176    const char *proxy = NULL;
4177    xs_list *p = (xs_list *)list;
4178    const char *v;
4179
4180    enqueue_actor_refresh(user, actor, 0);
4181
4182    if (xs_is_true(xs_dict_get(srv_config, "proxy_media")))
4183        proxy = user->actor;
4184
4185    xs_html *body = html_user_body(user, 0);
4186
4187    xs_html *lists = xs_html_tag("div",
4188        xs_html_attr("class", "snac-posts"));
4189
4190    xs *foll = xs_list_append(xs_list_new(), actor);
4191
4192    xs_html_add(lists,
4193        html_people_list(user, foll, L("Contact's posts"), "p", proxy, -1));
4194
4195    xs_html_add(body, lists);
4196
4197    while (xs_list_iter(&p, &v)) {
4198        xs *msg = NULL;
4199        int status;
4200
4201        status = timeline_get_by_md5(user, v, &msg);
4202
4203        if (!valid_status(status))
4204            continue;
4205
4206        const char *id = xs_dict_get(msg, "id");
4207        const char *by = get_atto(msg);
4208        xs *actor_md5 = NULL;
4209        xs_list *boosts = NULL;
4210        xs_list *likes = NULL;
4211        xs_list *reacts = NULL;
4212        /* Besides actor's posts, also show actor's boosts, and also
4213           posts by user with likes or reacts by actor.  I.e., any
4214           actor's actions that user could have seen in the timeline
4215           or in notifications.  */
4216        if (!(by && strcmp(actor, by) == 0) &&
4217            xs_list_in((boosts = object_announces(id)),
4218                       (actor_md5 = xs_md5_hex(actor, strlen(actor)))) == -1 &&
4219            (!(by && strcmp(user->actor, by) == 0) ||
4220             (xs_list_in((likes = object_likes(id)), actor_md5) == -1 &&
4221              xs_list_in((reacts = object_get_emoji_reacts(id)), actor_md5) == -1)))
4222            continue;
4223
4224        xs_html *entry = html_entry(user, msg, 0, 0, v, 1, 0);
4225
4226        if (entry != NULL)
4227            xs_html_add(lists,
4228                entry);
4229    }
4230
4231    if (show_more) {
4232        xs *m  = NULL;
4233        xs *m10  = NULL;
4234        xs *ss = xs_fmt("skip=%d&show=%d", skip + show, show);
4235
4236        xs *url = xs_dup(user == NULL ? srv_baseurl : user->actor);
4237
4238        if (page != NULL)
4239            url = xs_str_cat(url, page);
4240
4241        if (xs_str_in(url, "?") != -1)
4242            m = xs_fmt("%s&%s", url, ss);
4243        else
4244            m = xs_fmt("%s?%s", url, ss);
4245
4246        m10 = xs_fmt("%s0", m);
4247
4248        xs_html *more_links = xs_html_tag("p",
4249            xs_html_tag("a",
4250                xs_html_attr("href", url),
4251                xs_html_attr("name", "snac-more"),
4252                xs_html_text(L("Back to top"))),
4253            xs_html_text(" - "),
4254            xs_html_tag("a",
4255                xs_html_attr("href", m),
4256                xs_html_attr("name", "snac-more"),
4257                xs_html_text(L("More..."))),
4258            xs_html_text(" - "),
4259            xs_html_tag("a",
4260                xs_html_attr("href", m10),
4261                xs_html_attr("name", "snac-more"),
4262                xs_html_text(L("More (x 10)..."))));
4263
4264        xs_html_add(body,
4265            more_links);
4266    }
4267
4268    xs_html *html = xs_html_tag("html",
4269        html_user_head(user, NULL, NULL),
4270        xs_html_add(body,
4271            html_footer(user)));
4272
4273    return xs_html_render_s(html, "<!DOCTYPE html>\n");
4274}
4275
4276void notify_filter(snac *user, const xs_dict *p_vars)
4277/* sets filter for notifications */
4278{
4279    const char *v;
4280    int likes_on  = (v = xs_dict_get(p_vars, "likes_on")) ? strcmp(v, "on") == 0 : 0;
4281    int reacts_on = (v = xs_dict_get(p_vars, "reacts_on")) ? strcmp(v, "on") == 0 : 0;
4282    int ments_on  = (v = xs_dict_get(p_vars, "mentions_on")) ? strcmp(v, "on") == 0 : 0;
4283    int ann_on    = (v = xs_dict_get(p_vars, "announces_on")) ? strcmp(v, "on") == 0 : 0;
4284    int foll_on   = (v = xs_dict_get(p_vars, "follows_on")) ? strcmp(v, "on") == 0 : 0;
4285    int unfoll_on = (v = xs_dict_get(p_vars, "unfollows_on")) ? strcmp(v, "on") == 0 : 0;
4286    int folreq_on = (v = xs_dict_get(p_vars, "folreqs_on")) ? strcmp(v, "on") == 0 : 0;
4287    int blocks_on = (v = xs_dict_get(p_vars, "blocks_on")) ? strcmp(v, "on") == 0 : 0;
4288    int polls_on  = (v = xs_dict_get(p_vars, "polls_on")) ? strcmp(v, "on") == 0 : 0;
4289    int webmen_on  = (v = xs_dict_get(p_vars, "webmentions_on")) ? strcmp(v, "on") == 0 : 0;
4290    xs *filter = xs_dict_new();
4291    filter = xs_dict_set(filter, "likes", xs_stock(likes_on ? XSTYPE_TRUE : XSTYPE_FALSE));
4292    filter = xs_dict_set(filter, "reacts", xs_stock(reacts_on ? XSTYPE_TRUE : XSTYPE_FALSE));
4293    filter = xs_dict_set(filter, "mentions", xs_stock(ments_on ? XSTYPE_TRUE : XSTYPE_FALSE));
4294    filter = xs_dict_set(filter, "announces", xs_stock(ann_on ? XSTYPE_TRUE : XSTYPE_FALSE));
4295    filter = xs_dict_set(filter, "follows", xs_stock(foll_on ? XSTYPE_TRUE : XSTYPE_FALSE));
4296    filter = xs_dict_set(filter, "unfollows", xs_stock(unfoll_on ? XSTYPE_TRUE : XSTYPE_FALSE));
4297    filter = xs_dict_set(filter, "folreqs", xs_stock(folreq_on ? XSTYPE_TRUE : XSTYPE_FALSE));
4298    filter = xs_dict_set(filter, "blocks", xs_stock(blocks_on ? XSTYPE_TRUE : XSTYPE_FALSE));
4299    filter = xs_dict_set(filter, "polls", xs_stock(polls_on ? XSTYPE_TRUE : XSTYPE_FALSE));
4300    filter = xs_dict_set(filter, "webmentions", xs_stock(webmen_on ? XSTYPE_TRUE : XSTYPE_FALSE));
4301    user->config = xs_dict_set(user->config, "notify_filter", filter);
4302    user->tz = xs_dict_get_def(user->config, "tz", "UTC"); // previous line invalidates user->tz
4303}
4304
4305xs_str *html_notifications(snac *user, int skip, int show)
4306{
4307    const char *proxy = NULL;
4308
4309    if (xs_is_true(xs_dict_get(srv_config, "proxy_media")))
4310        proxy = user->actor;
4311
4312    xs *n_list_unfilt = notify_list(user, 0, XS_ALL);
4313    xs *n_time = notify_check_time(user, 0);
4314
4315    xs_html *body = html_user_body(user, 0);
4316    const xs_dict *n_filter = xs_dict_get(user->config, "notify_filter");
4317    if (!n_filter) {
4318        xs *filter = xs_dict_new();
4319        user->config = xs_dict_set(user->config, "notify_filter", filter);
4320        user->tz = xs_dict_get_def(user->config, "tz", "UTC"); // previous line invalidates user->tz
4321        n_filter = xs_dict_get(user->config, "notify_filter");
4322    }
4323    xs *n_list = notify_filter_list(user, n_list_unfilt, skip, show);
4324    /* all filters are true by default */
4325    const xs_val *n_def = xs_stock( XSTYPE_TRUE );
4326    int n_likes_on  = xs_is_true(xs_dict_get_def(n_filter, "likes", n_def));
4327    int n_reacts_on = xs_is_true(xs_dict_get_def(n_filter, "reacts", n_def));
4328    int n_ments_on  = xs_is_true(xs_dict_get_def(n_filter, "mentions", n_def));
4329    int n_ann_on    = xs_is_true(xs_dict_get_def(n_filter, "announces", n_def));
4330    int n_fol_on    = xs_is_true(xs_dict_get_def(n_filter, "follows", n_def));
4331    int n_unfol_on  = xs_is_true(xs_dict_get_def(n_filter, "unfollows", n_def));
4332    int n_folreq_on = xs_is_true(xs_dict_get_def(n_filter, "folreqs", n_def));
4333    int n_blocks_on = xs_is_true(xs_dict_get_def(n_filter, "blocks", n_def));
4334    int n_polls_on  = xs_is_true(xs_dict_get_def(n_filter, "polls", n_def));
4335    int n_webmen_on  = xs_is_true(xs_dict_get_def(n_filter, "webmentions", n_def));
4336
4337    xs_html *html = xs_html_tag("html",
4338        html_user_head(user, NULL, NULL),
4339        body);
4340
4341    xs *filter_notifs_action = xs_fmt("%s/admin/filter-notifications", user->actor);
4342    xs *notifs_action = xs_fmt("%s/notifications", user->actor);
4343    xs_html *notifs_form = xs_html_tag("form",
4344        xs_html_attr("autocomplete", "off"),
4345        xs_html_attr("method",       "post"),
4346        xs_html_attr("action",       filter_notifs_action),
4347        xs_html_attr("enctype",      "multipart/form-data"),
4348        xs_html_attr("id",           "filter"),
4349        xs_html_sctag("input",
4350            xs_html_attr("type",    "hidden"),
4351            xs_html_attr("name",    "hard-redir"),
4352            xs_html_attr("value",   notifs_action)),
4353        html_checkbox("likes_on", L("Likes"), n_likes_on),
4354        html_checkbox("reacts_on", L("Emoji reacts"), n_reacts_on),
4355        html_checkbox("mentions_on", L("Mentions"), n_ments_on),
4356        html_checkbox("announces_on", L("Announces"), n_ann_on),
4357        html_checkbox("follows_on", L("Follows"), n_fol_on),
4358        html_checkbox("unfollows_on", L("Unfollows"), n_unfol_on),
4359        html_checkbox("folreqs_on", L("Follow requests"), n_folreq_on),
4360        html_checkbox("blocks_on", L("Blocks"), n_blocks_on),
4361        html_checkbox("polls_on", L("Polls"), n_polls_on),
4362        html_checkbox("webmentions_on", L("Webmentions"), n_webmen_on),
4363        xs_html_sctag("input",
4364            xs_html_attr("type",     "submit"),
4365            xs_html_attr("class",    "button"),
4366            xs_html_attr("value",    L("Save"))));
4367
4368    xs_html_add(body,
4369        xs_html_tag("p",
4370            xs_html_tag("div",
4371                xs_html_attr("class", "snac-notify-filter"),
4372                xs_html_tag("details",
4373                    xs_html_tag("summary",
4374                        xs_html_text(L("Notifications filter..."))),
4375                    notifs_form))));
4376
4377    xs *clear_all_action = xs_fmt("%s/admin/clear-notifications", user->actor);
4378
4379    xs_html_add(body,
4380        xs_html_tag("form",
4381            xs_html_attr("autocomplete", "off"),
4382            xs_html_attr("method",       "post"),
4383            xs_html_attr("action",       clear_all_action),
4384            xs_html_attr("id",           "clear"),
4385            xs_html_sctag("input",
4386                xs_html_attr("type",     "submit"),
4387                xs_html_attr("class",    "snac-btn-clear-all"),
4388                xs_html_attr("value",    L("Clear all")))));
4389
4390    xs_html *noti_new = NULL;
4391    xs_html *noti_seen = NULL;
4392
4393    xs_html *posts = xs_html_tag("div",
4394        xs_html_attr("class", "snac-posts"));
4395    xs_html_add(body, posts);
4396
4397    xs_set rep;
4398    xs_set_init(&rep);
4399
4400    /* dict to store previous notification labels */
4401    xs *admiration_labels = xs_dict_new();
4402
4403    const xs_str *v;
4404
4405    xs_list_foreach(n_list, v) {
4406        xs *noti = notify_get(user, v);
4407
4408        if (noti == NULL)
4409            continue;
4410
4411        xs *obj = NULL;
4412        const char *type  = xs_dict_get(noti, "type");
4413        const char *utype = xs_dict_get(noti, "utype");
4414        const char *id    = xs_dict_get(noti, "objid");
4415        const char *date  = xs_dict_get(noti, "date");
4416        const char *id2   = xs_dict_get_path(noti, "msg.id");
4417        xs *wrk = NULL;
4418
4419        if (xs_is_null(id))
4420            continue;
4421
4422        if (is_hidden(user, id))
4423            continue;
4424
4425        if (xs_is_string(id2) && xs_set_add(&rep, id2) != 1)
4426            continue;
4427
4428        if (strcmp(type, "EmojiReact") == 0 && xs_is_true(xs_dict_get(srv_config, "disable_emojireact")))
4429            continue;
4430
4431        object_get(id, &obj);
4432
4433        const char *msg_id = NULL;
4434
4435        if (xs_is_dict(obj))
4436            msg_id = xs_dict_get(obj, "id");
4437
4438        const char *actor_id = xs_dict_get(noti, "actor");
4439        xs *actor = NULL;
4440        xs *a_name = NULL;
4441
4442        if (valid_status(actor_get(actor_id, &actor)))
4443            a_name = actor_name(actor, proxy);
4444        else {
4445            a_name = xs_dup(actor_id);
4446
4447            /* actor not here: request it */
4448            enqueue_actor_refresh(user, actor_id, 0);
4449        }
4450
4451        const char *html_url = xs_dict_get_def(actor, "url", actor_id);
4452
4453        if (xs_is_list(html_url))
4454            html_url = xs_list_get(html_url, 0);
4455
4456        if (!xs_is_string(html_url))
4457            html_url = actor_id;
4458
4459        xs *label_sanitized = sanitize(type);
4460        const char *label = label_sanitized;
4461
4462        if (strcmp(type, "Create") == 0)
4463            label = L("Mention");
4464        else
4465        if (strcmp(type, "Update") == 0 && strcmp(utype, "Question") == 0)
4466            label = L("Finished poll");
4467        else
4468        if (strcmp(type, "Undo") == 0 && strcmp(utype, "Follow") == 0)
4469            label = L("Unfollow");
4470        else
4471        if (strcmp(type, "EmojiReact") == 0 || strcmp(type, "Like") == 0) {
4472            const char *content = xs_dict_get_path(noti, "msg.content");
4473
4474            xs *cd = xs_dup(content);
4475            const char *sna = cd;
4476            const xs_dict *tag = xs_dict_get_path(noti, "msg.tag");
4477            unsigned int utf = xs_utf8_dec((const char **)&sna);
4478
4479            int isEmoji = 0;
4480            if (xs_is_emoji(utf) || (tag && xs_list_len(tag) > 0))
4481                isEmoji = 1;
4482
4483            if (xs_type(content) == XSTYPE_STRING) {
4484                if (!isEmoji && !n_likes_on)
4485                    continue;
4486                xs *emoji = replace_shortnames(xs_dup(content), xs_dict_get_path(noti, "msg.tag"), 1, proxy);
4487                wrk = xs_fmt("%s (%s&#xFE0F;)", isEmoji ? "EmojiReact" : "Like", emoji);
4488                label = wrk;
4489            }
4490        }
4491        else
4492        if (strcmp(type, "Follow") == 0 && pending_check(user, actor_id))
4493            label = L("Follow Request");
4494
4495        xs *s_date = html_date_label(user, date);
4496
4497        xs_html *this_html_label = xs_html_container(
4498                xs_html_tag("b",
4499                    xs_html_raw(label),
4500                    xs_html_text(" by "),
4501                    xs_html_tag("a",
4502                        xs_html_attr("href", html_url),
4503                        xs_html_raw(a_name))), /* a_name is already sanitized */
4504                xs_html_text(" "),
4505                xs_html_tag("time",
4506                    xs_html_attr("class", "dt-published snac-pubdate"),
4507                    xs_html_attr("title", date),
4508                    xs_html_text(s_date)));
4509
4510        xs_html *html_label = NULL;
4511
4512        if (xs_is_string(msg_id)) {
4513            const xs_val *prev_label = xs_dict_get(admiration_labels, msg_id);
4514
4515            if (xs_type(prev_label) == XSTYPE_DATA) {
4516                /* there is a previous list of admiration labels! */
4517                xs_data_get(&html_label, prev_label);
4518
4519                xs_html_add(html_label,
4520                    xs_html_sctag("br", NULL),
4521                    this_html_label);
4522
4523                continue;
4524            }
4525        }
4526
4527        xs_html *entry = NULL;
4528
4529        html_label = xs_html_tag("p",
4530            this_html_label);
4531
4532        /* store in the admiration labels dict */
4533        xs *pl = xs_data_new(&html_label, sizeof(html_label));
4534
4535        if (xs_is_string(msg_id))
4536            admiration_labels = xs_dict_set(admiration_labels, msg_id, pl);
4537
4538        entry = xs_html_tag("div",
4539            xs_html_attr("class", "snac-post-with-desc"),
4540            html_label);
4541
4542        if (strcmp(type, "Block") == 0) {
4543            if (actor)
4544                xs_html_add(entry,
4545                    xs_html_tag("div",
4546                        xs_html_attr("class", "snac-post"),
4547                        html_actor_icon(user, actor, NULL, NULL, NULL, -1, 0, proxy, NULL, NULL)));
4548        }
4549        else
4550        if (strcmp(type, "Follow") == 0 || strcmp(utype, "Follow") == 0) {
4551            if (actor) {
4552                xs *action = xs_fmt("%s/admin/action", user->actor);
4553                xs_html *button = NULL;
4554
4555                if (following_check(user, actor_id))
4556                    button = html_button("unfollow", L("Unfollow"), L("Stop following this user's activity"));
4557                else
4558                    button = html_button("follow", L("Follow"), L("Start following this user's activity"));
4559
4560                xs_html_add(entry,
4561                    xs_html_tag("div",
4562                        xs_html_attr("class", "snac-post"),
4563                        html_actor_icon(user, actor, NULL, NULL, NULL, -1, 0, proxy, NULL, NULL),
4564                        xs_html_tag("form",
4565                            xs_html_attr("method", "post"),
4566                            xs_html_attr("action", action),
4567                            xs_html_sctag("input",
4568                                xs_html_attr("type", "hidden"),
4569                                xs_html_attr("name", "actor"),
4570                                xs_html_attr("value", actor_id)),
4571                            button,
4572                            xs_html_sctag("br", NULL))));
4573            }
4574        }
4575        else
4576        if (strcmp(type, "Move") == 0) {
4577            const xs_dict *o_msg = xs_dict_get(noti, "msg");
4578            const char *target;
4579
4580            if (xs_type(o_msg) == XSTYPE_DICT && (target = xs_dict_get(o_msg, "target"))) {
4581                xs *old_actor = NULL;
4582
4583                if (valid_status(actor_get(target, &old_actor))) {
4584                    xs_html_add(entry,
4585                        xs_html_tag("div",
4586                            xs_html_attr("class", "snac-post"),
4587                            html_actor_icon(user, old_actor, NULL, NULL, NULL, -1, 0, proxy, NULL, NULL)));
4588                }
4589            }
4590        }
4591        else
4592        if (obj != NULL) {
4593            xs *md5 = xs_md5_hex(id, strlen(id));
4594            xs *ctxt = xs_fmt("%s/admin/p/%s#%s_entry", user->actor, md5, md5);
4595
4596            xs_html *h = html_entry(user, obj, 0, 0, md5, 1, 0);
4597
4598            if (h != NULL) {
4599                xs_html_add(entry,
4600                    xs_html_tag("p",
4601                        xs_html_tag("a",
4602                            xs_html_attr("href", ctxt),
4603                            xs_html_text(L("Context")))),
4604                    h);
4605            }
4606            else
4607                xs_html_add(entry,
4608                    xs_html_tag("p",
4609                        xs_html_text(L("Location: ")),
4610                        xs_html_tag("a",
4611                            xs_html_attr("href", id),
4612                            xs_html_text(id))));
4613        }
4614
4615        if (strcmp(v, n_time) > 0) {
4616            /* unseen notification */
4617            if (noti_new == NULL) {
4618                noti_new = xs_html_tag("div",
4619                    xs_html_tag("h2",
4620                        xs_html_attr("class", "snac-header"),
4621                        xs_html_text(L("New"))));
4622
4623                xs_html_add(posts,
4624                    noti_new);
4625            }
4626
4627            xs_html_add(noti_new,
4628                entry);
4629        }
4630        else {
4631            /* already seen notification */
4632            if (noti_seen == NULL) {
4633                noti_seen = xs_html_tag("div",
4634                    xs_html_tag("h2",
4635                        xs_html_attr("class", "snac-header"),
4636                        xs_html_text(L("Already seen"))));
4637
4638                xs_html_add(posts,
4639                    noti_seen);
4640            }
4641
4642            xs_html_add(noti_seen,
4643                entry);
4644        }
4645    }
4646
4647    if (noti_new == NULL && noti_seen == NULL)
4648        xs_html_add(body,
4649            xs_html_tag("h2",
4650                xs_html_attr("class", "snac-header"),
4651                xs_html_text(L("None"))));
4652
4653    /* add the navigation footer */
4654    xs *next_p = notify_filter_list(user, n_list_unfilt, skip + show, 1);
4655
4656    if (xs_list_len(next_p)) {
4657        xs *url = xs_fmt("%s/notifications?skip=%d&show=%d",
4658            user->actor, skip + show, show);
4659
4660        xs_html_add(body,
4661            xs_html_tag("p",
4662                xs_html_tag("a",
4663                    xs_html_attr("href", url),
4664                    xs_html_text(L("More...")))));
4665    }
4666
4667    xs_set_free(&rep);
4668
4669    xs_html_add(body,
4670        html_footer(user));
4671
4672    /* set the check time to now */
4673    xs *dummy = notify_check_time(user, 1);
4674    dummy = xs_free(dummy);
4675
4676    timeline_touch(user);
4677
4678    return xs_html_render_s(html, "<!DOCTYPE html>\n");
4679}
4680
4681
4682void set_user_lang(snac *user)
4683/* sets the language dict according to user configuration */
4684{
4685    user->lang = NULL;
4686    const char *lang = xs_dict_get(user->config, "lang");
4687
4688    if (xs_is_string(lang))
4689        user->lang = xs_dict_get(srv_langs, lang);
4690}
4691
4692
4693int text_browser_friendly(const snac *user, const char *user_agent)
4694/* returns true if the web UI must be adapted to text browsers */
4695/* basically, this means to avoid details/summary as much as possible */
4696{
4697    if (user != NULL && xs_is_string(user_agent)) {
4698        snac_debug(user, 2, xs_fmt("user-agent: %s", user_agent));
4699
4700        const char *text_browser_uas = xs_dict_get(user->config, "text_browser_uas");
4701
4702        if (xs_is_string(text_browser_uas)) {
4703            xs *l = xs_split(text_browser_uas, "\n");
4704            const char *v;
4705
4706            xs_list_foreach(l, v) {
4707                if (*v == '\0')
4708                    continue;
4709
4710                xs *v2 = xs_strip_i(xs_dup(v));
4711
4712                /* an asterisk alone means "all browsers", so yes */
4713                if (strcmp(v2, "*") == 0)
4714                    return 1;
4715
4716                /* if this string is inside user_agent, then yes */
4717                if (xs_str_in(user_agent, v2) != -1)
4718                    return 1;
4719            }
4720        }
4721    }
4722
4723    return 0;
4724}
4725
4726
4727int html_get_handler(const xs_dict *req, const char *q_path,
4728                     char **body, int *b_size, char **ctype,
4729                     xs_str **etag, xs_str **last_modified)
4730{
4731    const char *accept = xs_dict_get(req, "accept");
4732    int status = HTTP_STATUS_NOT_FOUND;
4733    const snac *user = NULL;
4734    snac snac;
4735    xs *uid = NULL;
4736    const char *p_path;
4737    int cache = 1;
4738    int save = 1;
4739    int proxy = 0;
4740    int terse = 0;
4741    int tb_friendly = 0;
4742    const char *v;
4743
4744    const xs_dict *q_vars = xs_dict_get(req, "q_vars");
4745
4746    xs *l = xs_split_n(q_path, "/", 2);
4747    v = xs_list_get(l, 1);
4748
4749    if (xs_is_null(v)) {
4750        srv_log(xs_fmt("html_get_handler bad query '%s'", q_path));
4751        return HTTP_STATUS_NOT_FOUND;
4752    }
4753
4754    if (!xs_is_null(xs_dict_get(q_vars, "terse")))
4755        terse = 1;
4756
4757    if (strcmp(v, "share-bridge") == 0) {
4758        /* temporary redirect for a post */
4759        const char *login = xs_dict_get(q_vars, "login");
4760        const char *content = xs_dict_get(q_vars, "content");
4761
4762        if (xs_type(login) == XSTYPE_STRING && xs_type(content) == XSTYPE_STRING) {
4763            xs *b64 = xs_base64_enc(content, strlen(content));
4764            srv_log(xs_fmt("share-bridge for user '%s'", login));
4765
4766            *body = xs_fmt("%s/%s/share?content=%s", srv_baseurl, login, b64);
4767            return HTTP_STATUS_SEE_OTHER;
4768        }
4769        else
4770            return HTTP_STATUS_NOT_FOUND;
4771    }
4772    else
4773    if (strcmp(v, "auth-int-bridge") == 0) {
4774        const char *login  = xs_dict_get(q_vars, "login");
4775        const char *id     = xs_dict_get(q_vars, "id");
4776        const char *action = xs_dict_get(q_vars, "action");
4777
4778        if (xs_is_string(login) && xs_is_string(id) && xs_is_string(action)) {
4779            *body = xs_fmt("%s/%s/authorize_interaction?action=%s&id=%s",
4780                srv_baseurl, login, action, id);
4781
4782            return HTTP_STATUS_SEE_OTHER;
4783        }
4784        else
4785            return HTTP_STATUS_NOT_FOUND;
4786    }
4787
4788    uid = xs_dup(v);
4789
4790    /* rss extension? */
4791    if (xs_endswith(uid, ".rss")) {
4792        uid = xs_crop_i(uid, 0, -4);
4793        p_path = ".rss";
4794    }
4795    else
4796        p_path = xs_list_get(l, 2);
4797
4798    if (!uid || !user_open(&snac, uid)) {
4799        /* invalid user */
4800        status = grave(uid, OP_CHECK) ? HTTP_STATUS_GONE : HTTP_STATUS_NOT_FOUND;
4801        srv_debug(2, xs_fmt("html_get_handler bad user '%s' %d", xs_or(uid, "(null)"), status));
4802        return status;
4803    }
4804
4805    user = &snac; /* for L() */
4806    set_user_lang(&snac);
4807
4808    if (xs_is_true(xs_dict_get(srv_config, "proxy_media")))
4809        proxy = 1;
4810
4811    tb_friendly = text_browser_friendly(user, xs_dict_get(req, "user-agent"));
4812
4813    /* return the RSS if requested by Accept header */
4814    if (accept != NULL) {
4815        if (xs_str_in(accept, "text/xml") != -1 ||
4816            xs_str_in(accept, "application/rss+xml") != -1)
4817            p_path = ".rss";
4818    }
4819
4820    /* check if server config variable 'disable_cache' is set */
4821    if ((v = xs_dict_get(srv_config, "disable_cache")) && xs_type(v) == XSTYPE_TRUE)
4822        cache = 0;
4823
4824    int skip = 0;
4825    const char *max_show_default = "50";
4826    int max_show = xs_number_get(xs_dict_get_def(srv_config, "max_timeline_entries",
4827                                 max_show_default));
4828    int def_show = xs_number_get(xs_dict_get_def(srv_config, "def_timeline_entries",
4829                                 xs_dict_get_def(srv_config, "max_timeline_entries",
4830                                 max_show_default)));
4831    int show = def_show;
4832
4833    if ((v = xs_dict_get(q_vars, "skip")) != NULL)
4834        skip = atoi(v), cache = 0, save = 0;
4835    if ((v = xs_dict_get(q_vars, "show")) != NULL)
4836        show = atoi(v), cache = 0, save = 0;
4837    if ((v = xs_dict_get(q_vars, "da")) != NULL) {
4838        /* user dismissed an announcement */
4839        if (login(&snac, req)) {
4840            double ts = atof(v);
4841            xs *timestamp = xs_number_new(ts);
4842            srv_log(xs_fmt("user dismissed announcements until %d", ts));
4843            snac.config = xs_dict_set(snac.config, "last_announcement", timestamp);
4844            user_persist(&snac, 0);
4845        }
4846    }
4847
4848    /* get a possible error message */
4849    const char *error = xs_dict_get(q_vars, "error");
4850    if (error != NULL)
4851        cache = 0;
4852
4853    /* a show of 0 has no sense */
4854    if (show == 0)
4855        show = def_show;
4856    if (show > max_show)
4857        show = max_show;
4858
4859    if (p_path == NULL) { /** public timeline **/
4860        xs *h = xs_str_localtime(0, "%Y-%m.html");
4861
4862        if (xs_type(xs_dict_get(snac.config, "private")) == XSTYPE_TRUE) {
4863            /** empty public timeline for private users **/
4864            *body = html_timeline(&snac, NULL, 1, 0, 0, 0, NULL, "", 1, error, terse, tb_friendly);
4865            *b_size = strlen(*body);
4866            status  = HTTP_STATUS_OK;
4867        }
4868        else
4869        if (cache && history_mtime(&snac, h) > timeline_mtime(&snac)) {
4870            snac_debug(&snac, 1, xs_fmt("serving cached local timeline"));
4871
4872            status = history_get(&snac, h, body, b_size,
4873                        xs_dict_get(req, "if-none-match"), etag);
4874        }
4875        else {
4876            xs *list = NULL;
4877            int more = 0;
4878
4879            if (xs_is_true(xs_dict_get(srv_config, "strict_public_timelines")))
4880                list = timeline_simple_list(&snac, "public", skip, show, &more);
4881            else
4882                list = timeline_list(&snac, "public", skip, show, &more);
4883
4884            xs *pins = pinned_list(&snac);
4885            pins = xs_list_cat(pins, list);
4886
4887            *body = html_timeline(&snac, pins, 1, skip, show, more, NULL, "", 1, error, terse, tb_friendly);
4888
4889            *b_size = strlen(*body);
4890            status  = HTTP_STATUS_OK;
4891
4892            if (save)
4893                history_add(&snac, h, *body, *b_size, etag);
4894        }
4895    }
4896    else
4897    if (strcmp(p_path, "operations") == 0) { /** operations and user settings **/
4898        if (!login(&snac, req)) {
4899            *body  = xs_dup(uid);
4900            status = HTTP_STATUS_UNAUTHORIZED;
4901        }
4902        else {
4903            xs_html *h_body = html_user_body(&snac, 0);
4904
4905            xs_html *html = xs_html_tag("html",
4906                html_user_head(&snac, NULL, NULL),
4907                h_body);
4908
4909            xs_html_add(h_body,
4910                html_top_controls(&snac, 0),
4911                html_footer(&snac));
4912
4913            *body   = xs_html_render_s(html, "<!DOCTYPE html>\n");
4914            *b_size = strlen(*body);
4915            status  = HTTP_STATUS_OK;
4916        }
4917    }
4918    else
4919    if (strcmp(p_path, "admin") == 0) { /** private timeline **/
4920        if (!login(&snac, req)) {
4921            *body  = xs_dup(uid);
4922            status = HTTP_STATUS_UNAUTHORIZED;
4923        }
4924        else {
4925            const char *q = NULL;
4926            xs *cq = xs_dup(xs_dict_get(q_vars, "q"));
4927            xs *url_acct = NULL;
4928
4929            if (xs_is_string(cq)) {
4930                cq = xs_strip_i(cq);
4931                q = cq;
4932            }
4933
4934            /* searching for an URL? */
4935            if (q && xs_match(q, "https://*|http://*")) {
4936                /* bring it to the user's timeline */
4937                xs *actor_obj = NULL;
4938                xs *object = NULL;
4939                int status;
4940
4941                status = activitypub_request(&snac, q, &object);
4942                snac_debug(&snac, 1, xs_fmt("Request searched URL %s %d", q, status));
4943
4944                if (valid_status(status) && xs_is_dict(object)) {
4945                    /* got it; also request the actor */
4946                    const char *attr_to = get_atto(object);
4947
4948                    if (!xs_is_null(attr_to)) {
4949                        status = actor_request(&snac, attr_to, &actor_obj);
4950
4951                        if (valid_status(status)) {
4952                            /* reset the query string to be the real id */
4953                            url_acct = xs_dup(xs_dict_get(object, "id"));
4954                            q = url_acct;
4955
4956                            /* add the post to the timeline */
4957                            if (!timeline_here(&snac, q))
4958                                timeline_add(&snac, q, object);
4959                        }
4960                    }
4961                    else {
4962                        /* retry webfinger, this time with the 'official' id */
4963                        const char *id = xs_dict_get(object, "id");
4964
4965                        if (xs_is_string(id) && valid_status(webfinger_request_fake(id, &actor_obj, &url_acct)) &&
4966                            xs_is_string(url_acct))
4967                            q = url_acct;
4968                    }
4969                }
4970                else
4971                /* may by an actor; try a webfinger */
4972                if (valid_status(webfinger_request(q, &actor_obj, &url_acct)) && xs_is_string(url_acct)) {
4973                    /* it's an actor; do the dirty trick of changing q to the account name */
4974                    q = url_acct;
4975                }
4976
4977                /* fall through */
4978            }
4979
4980            if (q && *q) {
4981                if (xs_regex_match(q, "^@?[^@]+@[a-zA-Z0-9-]+\\.")) {
4982                    /** search account **/
4983                    xs *actor = NULL;
4984                    xs *acct = NULL;
4985                    xs *l = xs_list_new();
4986                    xs_html *page = NULL;
4987
4988                    if (valid_status(webfinger_request(q, &actor, &acct))) {
4989                        xs *actor_obj = NULL;
4990
4991                        if (valid_status(actor_request(&snac, actor, &actor_obj))) {
4992                            actor_add(actor, actor_obj);
4993
4994                            /* create a people list with only one element */
4995                            l = xs_list_append(l, actor);
4996
4997                            xs *title = xs_fmt(L("Search results for account %s"), q);
4998
4999                            page = html_people_list(&snac, l, title, "wf", NULL, 1);
5000                        }
5001                    }
5002
5003                    if (page == NULL) {
5004                        xs *title = xs_fmt(L("Account %s not found"), q);
5005
5006                        page = xs_html_tag("div",
5007                            xs_html_tag("h2",
5008                                xs_html_attr("class", "snac-header"),
5009                                xs_html_text(title)));
5010                    }
5011
5012                    xs_html *html = xs_html_tag("html",
5013                        html_user_head(&snac, NULL, NULL),
5014                        xs_html_add(html_user_body(&snac, 0),
5015                        page,
5016                        html_footer(user)));
5017
5018                    *body = xs_html_render_s(html, "<!DOCTYPE html>\n");
5019                    *b_size = strlen(*body);
5020                    status = HTTP_STATUS_OK;
5021                }
5022                else
5023                if (*q == '#') {
5024                    /** search by tag **/
5025                    xs *tl = tag_search(q, skip, show + 1);
5026                    int more = 0;
5027                    if (xs_list_len(tl) >= show + 1) {
5028                        /* drop the last one */
5029                        tl = xs_list_del(tl, -1);
5030                        more = 1;
5031                    }
5032
5033                    xs *page = xs_fmt("/admin?q=%%23%s", q + 1);
5034                    xs *title = xs_fmt(xs_list_len(tl) ?
5035                        L("Search results for tag %s") : L("Nothing found for tag %s"), q);
5036
5037                    *body = html_timeline(&snac, tl, 0, skip, show, more, title, page, 0, error, terse, tb_friendly);
5038                    *b_size = strlen(*body);
5039                    status  = HTTP_STATUS_OK;
5040                }
5041                else {
5042                    /** search by content **/
5043                    int to = 0;
5044                    int msecs = atoi(xs_dict_get_def(q_vars, "msecs", "0"));
5045                    xs *tl = content_search(&snac, q, 1, skip, show, msecs, &to);
5046                    xs *title = NULL;
5047                    xs *page = xs_fmt("/admin?q=%s&msecs=%d", q, msecs + 10);
5048                    int tl_len = xs_list_len(tl);
5049
5050                    if (to)
5051                        title = xs_fmt(L("Search results for '%s' (may be more)"), q);
5052                    else
5053                    if (tl_len)
5054                        title = xs_fmt(L("Search results for '%s'"), q);
5055                    else
5056                    if (skip)
5057                        title = xs_fmt(L("No more matches for '%s'"), q);
5058                    else
5059                        title = xs_fmt(L("Nothing found for '%s'"), q);
5060
5061                    *body   = html_timeline(&snac, tl, 0, skip, tl_len, to || tl_len == show,
5062                                            title, page, 0, error, terse, tb_friendly);
5063                    *b_size = strlen(*body);
5064                    status  = HTTP_STATUS_OK;
5065                }
5066            }
5067            else {
5068                /** the private timeline **/
5069                double t = history_mtime(&snac, "timeline.html_");
5070
5071                /* if enabled by admin, return a cached page if its timestamp is:
5072                   a) newer than the timeline timestamp
5073                   b) newer than the start time of the server
5074                */
5075                if (cache && t > timeline_mtime(&snac) && t > p_state->srv_start_time) {
5076                    snac_debug(&snac, 1, xs_fmt("serving cached timeline"));
5077
5078                    status = history_get(&snac, "timeline.html_", body, b_size,
5079                                xs_dict_get(req, "if-none-match"), etag);
5080                }
5081                else {
5082                    int more = 0;
5083
5084                    snac_debug(&snac, 1, xs_fmt("building timeline"));
5085
5086                    xs *list = timeline_list(&snac, "private", skip, show, &more);
5087
5088                    *body = html_timeline(&snac, list, 0, skip, show,
5089                            more, NULL, "/admin", 1, error, terse, tb_friendly);
5090
5091                    *b_size = strlen(*body);
5092                    status  = HTTP_STATUS_OK;
5093
5094                    if (save)
5095                        history_add(&snac, "timeline.html_", *body, *b_size, etag);
5096
5097                    timeline_add_mark(&snac);
5098                }
5099            }
5100        }
5101    }
5102    else
5103    if (xs_startswith(p_path, "admin/p/")) { /** unique post by md5 **/
5104        if (!login(&snac, req)) {
5105            *body  = xs_dup(uid);
5106            status = HTTP_STATUS_UNAUTHORIZED;
5107        }
5108        else {
5109            xs *l = xs_split(p_path, "/");
5110            const char *md5 = xs_list_get(l, -1);
5111
5112            if (md5 && *md5 && timeline_here_by_md5(&snac, md5)) {
5113                xs *list0 = xs_list_append(xs_list_new(), md5);
5114                xs *list  = timeline_top_level(&snac, list0);
5115
5116                *body   = html_timeline(&snac, list, 0, 0, 0, 0, NULL, "/admin", 1, error, terse, tb_friendly);
5117                *b_size = strlen(*body);
5118                status  = HTTP_STATUS_OK;
5119            }
5120        }
5121    }
5122    else
5123    if (strcmp(p_path, "people") == 0) { /** the list of people **/
5124        if (!login(&snac, req)) {
5125            *body  = xs_dup(uid);
5126            status = HTTP_STATUS_UNAUTHORIZED;
5127        }
5128        else {
5129            *body   = html_people(&snac);
5130            *b_size = strlen(*body);
5131            status  = HTTP_STATUS_OK;
5132        }
5133    }
5134    else
5135    if (xs_startswith(p_path, "people/")) { /** a single actor **/
5136        if (!login(&snac, req)) {
5137            *body  = xs_dup(uid);
5138            status = HTTP_STATUS_UNAUTHORIZED;
5139        }
5140        else {
5141            xs *actor_dict = NULL;
5142            const char *actor_id = NULL;
5143            xs *actor = NULL;
5144            xs_list *page_lst = xs_split_n(p_path, "?", 2);
5145            xs *page = xs_str_cat(xs_str_new("/"), xs_list_get(page_lst, 0));
5146            xs_list *l = xs_split_n(page, "/", 3);
5147            const char *actor_md5 = xs_list_get(l, 2);
5148
5149            if (valid_status(object_get_by_md5(actor_md5, &actor_dict)) &&
5150               (actor_id = xs_dict_get(actor_dict, "id")) != NULL &&
5151               valid_status(actor_get(actor_id, &actor))) {
5152                int more = 0;
5153                xs *list = timeline_simple_list(&snac, "private", skip, show, &more);
5154
5155                *body   = html_people_one(&snac, actor_id, list, skip, show, more, page);
5156                *b_size = strlen(*body);
5157                status  = HTTP_STATUS_OK;
5158            }
5159            else {
5160                *body = xs_dup(uid);
5161                status = HTTP_STATUS_NOT_FOUND;
5162            }
5163        }
5164    }
5165    else
5166    if (strcmp(p_path, "notifications") == 0) { /** the list of notifications **/
5167        if (!login(&snac, req)) {
5168            *body  = xs_dup(uid);
5169            status = HTTP_STATUS_UNAUTHORIZED;
5170        }
5171        else {
5172            *body   = html_notifications(&snac, skip, show);
5173            *b_size = strlen(*body);
5174            status  = HTTP_STATUS_OK;
5175        }
5176    }
5177    else
5178    if (strcmp(p_path, "instance") == 0) { /** instance timeline **/
5179        if (!login(&snac, req)) {
5180            *body  = xs_dup(uid);
5181            status = HTTP_STATUS_UNAUTHORIZED;
5182        }
5183        else {
5184            xs *list = timeline_instance_list(skip, show);
5185            xs *next = timeline_instance_list(skip + show, 1);
5186
5187            *body = html_timeline(&snac, list, 0, skip, show,
5188                xs_list_len(next), L("Showing instance timeline"), "/instance", 0, error, terse, tb_friendly);
5189            *b_size = strlen(*body);
5190            status  = HTTP_STATUS_OK;
5191        }
5192    }
5193    else
5194    if (strcmp(p_path, "pinned") == 0) { /** list of pinned posts **/
5195        if (!login(&snac, req)) {
5196            *body  = xs_dup(uid);
5197            status = HTTP_STATUS_UNAUTHORIZED;
5198        }
5199        else {
5200            xs *list = pinned_list(&snac);
5201
5202            *body = html_timeline(&snac, list, 0, skip, show,
5203                0, L("Pinned posts"), "", 0, error, terse, tb_friendly);
5204            *b_size = strlen(*body);
5205            status  = HTTP_STATUS_OK;
5206        }
5207    }
5208    else
5209    if (strcmp(p_path, "admirations") == 0) { /** list of admired posts **/
5210        if (!login(&snac, req)) {
5211            *body  = xs_dup(uid);
5212            status = HTTP_STATUS_UNAUTHORIZED;
5213        }
5214        else {
5215            int more = 0;
5216            xs *list = timeline_list(&snac, "admire", skip, show, &more);
5217
5218            *body = html_timeline(&snac, list, 0, skip, show,
5219                more, L("Liked, boosted or reacted posts"), "/admirations", 0, error, terse, tb_friendly);
5220            *b_size = strlen(*body);
5221            status  = HTTP_STATUS_OK;
5222        }
5223    }
5224    else
5225    if (strcmp(p_path, "bookmarks") == 0) { /** list of bookmarked posts **/
5226        if (!login(&snac, req)) {
5227            *body  = xs_dup(uid);
5228            status = HTTP_STATUS_UNAUTHORIZED;
5229        }
5230        else {
5231            xs *list = bookmark_list(&snac);
5232
5233            *body = html_timeline(&snac, list, 0, skip, show,
5234                0, L("Bookmarked posts"), "", 0, error, terse, tb_friendly);
5235            *b_size = strlen(*body);
5236            status  = HTTP_STATUS_OK;
5237        }
5238    }
5239    else
5240    if (strcmp(p_path, "drafts") == 0) { /** list of drafts **/
5241        if (!login(&snac, req)) {
5242            *body  = xs_dup(uid);
5243            status = HTTP_STATUS_UNAUTHORIZED;
5244        }
5245        else {
5246            xs *list = draft_list(&snac);
5247
5248            *body = html_timeline(&snac, list, 0, skip, show,
5249                0, L("Post drafts"), "", 0, error, terse, tb_friendly);
5250            *b_size = strlen(*body);
5251            status  = HTTP_STATUS_OK;
5252        }
5253    }
5254    else
5255    if (strcmp(p_path, "sched") == 0) { /** list of scheduled posts **/
5256        if (!login(&snac, req)) {
5257            *body  = xs_dup(uid);
5258            status = HTTP_STATUS_UNAUTHORIZED;
5259        }
5260        else {
5261            xs *list = scheduled_list(&snac);
5262
5263            *body = html_timeline(&snac, list, 0, skip, show,
5264                0, L("Scheduled posts"), "", 0, error, terse, tb_friendly);
5265            *b_size = strlen(*body);
5266            status  = HTTP_STATUS_OK;
5267        }
5268    }
5269    else
5270    if (xs_startswith(p_path, "list/")) { /** list timelines **/
5271        if (!login(&snac, req)) {
5272            *body  = xs_dup(uid);
5273            status = HTTP_STATUS_UNAUTHORIZED;
5274        }
5275        else {
5276            xs *l = xs_split(p_path, "/");
5277            const char *lid = xs_list_get(l, -1);
5278
5279            xs *list = list_timeline(&snac, lid, skip, show);
5280            xs *next = list_timeline(&snac, lid, skip + show, 1);
5281
5282            if (list != NULL) {
5283                xs *ttl = timeline_top_level(&snac, list);
5284
5285                xs *base = xs_fmt("/list/%s", lid);
5286                xs *name = list_maint(&snac, lid, OP_ID);
5287                xs *title = xs_fmt(L("Showing timeline for list '%s'"), name);
5288
5289                *body = html_timeline(&snac, ttl, 0, skip, show,
5290                    xs_list_len(next), title, base, 1, error, terse, tb_friendly);
5291                *b_size = strlen(*body);
5292                status  = HTTP_STATUS_OK;
5293            }
5294        }
5295    }
5296    else
5297    if (xs_startswith(p_path, "p/")) { /** a timeline with just one entry **/
5298        if (xs_type(xs_dict_get(snac.config, "private")) == XSTYPE_TRUE)
5299            return HTTP_STATUS_FORBIDDEN;
5300
5301        xs *id  = xs_fmt("%s/%s", snac.actor, p_path);
5302        xs *msg = NULL;
5303
5304        if (valid_status(object_get(id, &msg)) && is_msg_public(msg)) {
5305            xs *md5  = xs_md5_hex(id, strlen(id));
5306            xs *list = xs_list_new();
5307
5308            list = xs_list_append(list, md5);
5309
5310            *body   = html_timeline(&snac, list, 1, 0, 0, 0, NULL, "", 1, error, terse, tb_friendly);
5311            *b_size = strlen(*body);
5312            status  = HTTP_STATUS_OK;
5313        }
5314    }
5315    else
5316    if (xs_startswith(p_path, "s/")) { /** a static file **/
5317        xs *l    = xs_split_n(p_path, "/", 1);
5318        const char *id = xs_list_get(l, 1);
5319        int sz;
5320
5321        if (id && *id) {
5322            status = static_get(&snac, id, body, &sz,
5323                        xs_dict_get(req, "if-none-match"), etag);
5324
5325            if (valid_status(status)) {
5326                *b_size = sz;
5327                *ctype  = (char *)xs_mime_by_ext(id);
5328            }
5329        }
5330    }
5331    else
5332    if (xs_startswith(p_path, "h/")) { /** an entry from the history **/
5333        if (xs_type(xs_dict_get(snac.config, "private")) == XSTYPE_TRUE)
5334            return HTTP_STATUS_FORBIDDEN;
5335
5336        if (xs_type(xs_dict_get(srv_config, "disable_history")) == XSTYPE_TRUE)
5337            return HTTP_STATUS_FORBIDDEN;
5338
5339        xs *l = xs_split(p_path, "/");
5340        const char *id = xs_list_get(l, 1);
5341
5342        if (id && *id) {
5343            if (xs_endswith(id, "timeline.html_")) {
5344                /* Don't let them in */
5345                *b_size = 0;
5346                status = HTTP_STATUS_NOT_FOUND;
5347            }
5348            else
5349                status = history_get(&snac, id, body, b_size,
5350                            xs_dict_get(req, "if-none-match"), etag);
5351        }
5352    }
5353    else
5354    if (strcmp(p_path, ".rss") == 0) { /** public timeline in RSS format **/
5355        if (xs_type(xs_dict_get(snac.config, "private")) == XSTYPE_TRUE)
5356            return HTTP_STATUS_FORBIDDEN;
5357
5358        int cnt = xs_number_get(xs_dict_get_def(srv_config, "max_public_entries", "20"));
5359
5360        xs *elems = timeline_simple_list(&snac, "public", 0, cnt, NULL);
5361        xs *bio   = xs_dup(xs_dict_get(snac.config, "bio"));
5362
5363        xs *rss_title = xs_fmt("%s (@%s@%s)",
5364            xs_dict_get(snac.config, "name"),
5365            snac.uid,
5366            xs_dict_get(srv_config, "host"));
5367        xs *rss_link = xs_fmt("%s.rss", snac.actor);
5368
5369        *body   = rss_from_timeline(&snac, elems, rss_title, rss_link, bio);
5370        *b_size = strlen(*body);
5371        *ctype  = "application/rss+xml; charset=utf-8";
5372        status  = HTTP_STATUS_OK;
5373
5374        snac_debug(&snac, 1, xs_fmt("serving RSS"));
5375    }
5376    else
5377    if (proxy && (xs_startswith(p_path, "x/") || xs_startswith(p_path, "y/"))) { /** remote media by proxy **/
5378        xs *proxy_prefix = NULL;
5379
5380        if (xs_startswith(p_path, "x/")) {
5381            /* proxy usage authorized by http basic auth */
5382            if (login(&snac, req))
5383                proxy_prefix = xs_str_new("x/");
5384            else {
5385                *body  = xs_dup(uid);
5386                status = HTTP_STATUS_UNAUTHORIZED;
5387            }
5388        }
5389        else {
5390            /* proxy usage authorized by proxy_token */
5391            xs *tks = xs_fmt("%s:%s", srv_proxy_token_seed, snac.actor);
5392            xs *tk = xs_md5_hex(tks, strlen(tks));
5393            xs *p = xs_fmt("y/%s/", tk);
5394
5395            if (xs_startswith(p_path, p))
5396                proxy_prefix = xs_dup(p);
5397        }
5398
5399        if (proxy_prefix) {
5400            /* pick the raw path (including optional ? arguments) */
5401            const char *raw_path = xs_dict_get(req, "raw_path");
5402
5403            /* skip the prefix and user part */
5404            xs *s = xs_fmt("%s/%s/", xs_dict_get(srv_config, "prefix"), snac.uid);
5405            raw_path += strlen(s);
5406
5407            /* skip to where the proxy/ string starts */
5408            raw_path += xs_str_in(raw_path, proxy_prefix);
5409
5410            xs *url = xs_replace_n(raw_path, proxy_prefix, "https:/" "/", 1);
5411            xs *hdrs = xs_dict_new();
5412
5413            hdrs = xs_dict_append(hdrs, "user-agent", USER_AGENT);
5414
5415            const char *ims = xs_dict_get(req, "if-modified-since");
5416            const char *inm = xs_dict_get(req, "if-none-match");
5417
5418            if (ims) hdrs = xs_dict_append(hdrs, "if-modified-since", ims);
5419            if (inm) hdrs = xs_dict_append(hdrs, "if-none-match", inm);
5420
5421            xs *rsp = xs_http_request("GET", url, hdrs,
5422                        NULL, 0, &status, body, b_size, 0);
5423
5424            if (valid_status(status)) {
5425                const char *ct = xs_or(xs_dict_get(rsp, "content-type"), "");
5426                const char *lm = xs_dict_get(rsp, "last-modified");
5427                const char *et = xs_dict_get(rsp, "etag");
5428
5429                if (lm) *last_modified = xs_dup(lm);
5430                if (et) *etag = xs_dup(et);
5431
5432                /* find the content-type in the static mime types,
5433                   and return that value instead of ct, which will
5434                   be destroyed when out of scope */
5435                for (int n = 0; xs_mime_types[n]; n += 2) {
5436                    if (strcmp(ct, xs_mime_types[n + 1]) == 0) {
5437                        *ctype = (char *)xs_mime_types[n + 1];
5438                        break;
5439                    }
5440                }
5441            }
5442
5443            snac_debug(&snac, 1, xs_fmt("Proxy for %s %d", url, status));
5444
5445            if (status >= 400 && status <= 499)
5446                status = HTTP_STATUS_NOT_FOUND;
5447        }
5448    }
5449    else
5450    if (strcmp(p_path, "share") == 0) { /** direct post **/
5451        if (!login(&snac, req)) {
5452            *body  = xs_dup(uid);
5453            status = HTTP_STATUS_UNAUTHORIZED;
5454        }
5455        else {
5456            const char *b64 = xs_dict_get(q_vars, "content");
5457            xs *b64_2 = xs_replace(b64, " ", "+");
5458            int sz;
5459            xs *content = xs_base64_dec(b64_2, &sz);
5460            xs *msg = msg_note(&snac, content, NULL, NULL, NULL, 0, NULL, NULL);
5461            xs *c_msg = msg_create(&snac, msg);
5462
5463            timeline_add(&snac, xs_dict_get(msg, "id"), msg);
5464
5465            enqueue_message(&snac, c_msg);
5466
5467            snac_debug(&snac, 1, xs_fmt("web action 'share' received"));
5468
5469            *body   = xs_fmt("%s/admin", snac.actor);
5470            *b_size = strlen(*body);
5471            status  = HTTP_STATUS_SEE_OTHER;
5472        }
5473    }
5474    else
5475    if (strcmp(p_path, "authorize_interaction") == 0) { /** follow, like or boost from Mastodon **/
5476        if (!login(&snac, req)) {
5477            *body  = xs_dup(uid);
5478            status = HTTP_STATUS_UNAUTHORIZED;
5479        }
5480        else {
5481            status = HTTP_STATUS_NOT_FOUND;
5482
5483            const char *id     = xs_dict_get(q_vars, "id");
5484            const char *action = xs_dict_get(q_vars, "action");
5485
5486            if (xs_is_string(id) && xs_is_string(action)) {
5487                if (strcmp(action, "Follow") == 0) {
5488                    xs *msg = msg_follow(&snac, id);
5489
5490                    if (msg != NULL) {
5491                        const char *actor = xs_dict_get(msg, "object");
5492
5493                        following_add(&snac, actor, msg);
5494
5495                        enqueue_output_by_actor(&snac, msg, actor, 0);
5496
5497                        status = HTTP_STATUS_SEE_OTHER;
5498                    }
5499                }
5500                else
5501                if (xs_match(action, "Like|Boost|Announce")) {
5502                    /* bring the post */
5503                    xs *msg = msg_admiration(&snac, id, *action == 'L' ? "Like" : "Announce");
5504
5505                    if (msg != NULL) {
5506                        timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, *action == 'L' ? 1 : 0, msg);
5507                        enqueue_message(&snac, msg);
5508
5509                        status = HTTP_STATUS_SEE_OTHER;
5510                    }
5511                }
5512            }
5513
5514            if (status == HTTP_STATUS_SEE_OTHER) {
5515                *body   = xs_fmt("%s/admin", snac.actor);
5516                *b_size = strlen(*body);
5517            }
5518        }
5519    }
5520    else
5521        status = HTTP_STATUS_NOT_FOUND;
5522
5523    user_free(&snac);
5524
5525    if (valid_status(status) && *ctype == NULL) {
5526        *ctype = "text/html; charset=utf-8";
5527    }
5528
5529    return status;
5530}
5531
5532
5533int html_post_handler(const xs_dict *req, const char *q_path,
5534                      char *payload, int p_size,
5535                      char **body, int *b_size, char **ctype)
5536{
5537    (void)p_size;
5538    (void)ctype;
5539
5540    int status = 0;
5541    const snac *user = NULL;
5542    snac snac;
5543    const char *uid;
5544    const char *p_path;
5545    const xs_dict *p_vars;
5546
5547    xs *l = xs_split_n(q_path, "/", 2);
5548
5549    uid = xs_list_get(l, 1);
5550    if (!uid || !user_open(&snac, uid)) {
5551        /* invalid user */
5552        srv_debug(1, xs_fmt("html_post_handler bad user %s", xs_or(uid, "(null)")));
5553        return HTTP_STATUS_NOT_FOUND;
5554    }
5555
5556    p_path = xs_list_get(l, 2);
5557
5558    /* all posts must be authenticated */
5559    if (!login(&snac, req)) {
5560        user_free(&snac);
5561        *body  = xs_dup(uid);
5562        return HTTP_STATUS_UNAUTHORIZED;
5563    }
5564
5565    user = &snac; /* for L() */
5566    set_user_lang(&snac);
5567
5568    p_vars = xs_dict_get(req, "p_vars");
5569
5570    if (p_path && strcmp(p_path, "admin/note") == 0) { /** **/
5571        snac_debug(&snac, 1, xs_fmt("web action '%s' received", p_path));
5572
5573        /* post note */
5574        const char *content      = xs_dict_get(p_vars, "content");
5575        const char *in_reply_to  = xs_dict_get(p_vars, "in_reply_to");
5576        const char *to           = xs_dict_get(p_vars, "to");
5577        const char *sensitive    = xs_dict_get(p_vars, "sensitive");
5578        const char *summary      = xs_dict_get(p_vars, "summary");
5579        const char *edit_id      = xs_dict_get(p_vars, "edit_id");
5580        const char *post_date    = xs_dict_get_def(p_vars, "post_date", "");
5581        const char *post_time    = xs_dict_get_def(p_vars, "post_time", "");
5582        const char *post_lang    = xs_dict_get(p_vars, "post_lang");
5583        const char *visibility   = xs_dict_get(p_vars, "visibility");
5584        int scope = SCOPE_PUBLIC;  /* default to public */
5585        if (!xs_is_null(visibility)) {
5586            if (strcmp(visibility, "unlisted") == 0)
5587                scope = SCOPE_UNLISTED;
5588            else
5589            if (strcmp(visibility, "followers") == 0)
5590                scope = SCOPE_FOLLOWERS;
5591            else
5592            if (strcmp(visibility, "mentioned") == 0)
5593                scope = SCOPE_MENTIONED;
5594        }
5595        int store_as_draft   = !xs_is_null(xs_dict_get(p_vars, "is_draft"));
5596        xs *attach_list      = xs_list_new();
5597
5598        /* iterate the attachments */
5599        int max_attachments = xs_number_get(xs_dict_get_def(srv_config, "max_attachments", "4"));
5600
5601        for (int att_n = 0; att_n < max_attachments; att_n++) {
5602            xs *url_lbl = xs_fmt("attach_url_%d", att_n);
5603            xs *att_lbl = xs_fmt("attach_%d", att_n);
5604            xs *alt_lbl = xs_fmt("alt_text_%d", att_n);
5605
5606            const char *attach_url     = xs_dict_get(p_vars, url_lbl);
5607            const xs_list *attach_file = xs_dict_get(p_vars, att_lbl);
5608            const char *alt_text       = xs_dict_get_def(p_vars, alt_lbl, "");
5609
5610            if (xs_is_string(attach_url) && *attach_url != '\0') {
5611                xs *l = xs_list_new();
5612
5613                l = xs_list_append(l, attach_url);
5614                l = xs_list_append(l, alt_text);
5615
5616                attach_list = xs_list_append(attach_list, l);
5617            }
5618            else
5619            if (xs_is_list(attach_file)) {
5620                const char *fn = xs_list_get(attach_file, 0);
5621
5622                if (xs_is_string(fn) && *fn != '\0') {
5623                    char rnd[32];
5624                    xs_rnd_buf(rnd, sizeof(rnd));
5625
5626                    const char *ext = strrchr(fn, '.');
5627                    xs *hash  = xs_md5_hex(rnd, sizeof(rnd));
5628                    xs *id    = xs_fmt("post-%s%s", hash, ext ? ext : "");
5629                    xs *url   = xs_fmt("%s/s/%s", snac.actor, id);
5630                    int fo    = xs_number_get(xs_list_get(attach_file, 1));
5631                    int fs    = xs_number_get(xs_list_get(attach_file, 2));
5632
5633                    /* store */
5634                    static_put(&snac, id, payload + fo, fs);
5635
5636                    xs *l = xs_list_new();
5637
5638                    l = xs_list_append(l, url);
5639                    l = xs_list_append(l, alt_text);
5640
5641                    attach_list = xs_list_append(attach_list, l);
5642                }
5643            }
5644        }
5645
5646        if (content != NULL) {
5647            xs *msg       = NULL;
5648            xs *c_msg     = NULL;
5649            xs *content_2 = xs_replace(content, "\r", "");
5650            xs *poll_opts = NULL;
5651
5652            /* is there a valid set of poll options? */
5653            const char *v = xs_dict_get(p_vars, "poll_options");
5654            if (!xs_is_null(v) && *v) {
5655                xs *v2 = xs_strip_i(xs_replace(v, "\r", ""));
5656
5657                poll_opts = xs_split(v2, "\n");
5658            }
5659
5660            if (!xs_is_null(poll_opts) && xs_list_len(poll_opts)) {
5661                /* get the rest of poll configuration */
5662                const char *p_multiple = xs_dict_get(p_vars, "poll_multiple");
5663                const char *p_end_secs = xs_dict_get(p_vars, "poll_end_secs");
5664                int multiple = 0;
5665
5666                int end_secs = atoi(!xs_is_null(p_end_secs) ? p_end_secs : "60");
5667
5668                if (!xs_is_null(p_multiple) && strcmp(p_multiple, "on") == 0)
5669                    multiple = 1;
5670
5671                msg = msg_question(&snac, content_2, attach_list,
5672                                   poll_opts, multiple, end_secs);
5673
5674                enqueue_close_question(&snac, xs_dict_get(msg, "id"), end_secs);
5675            }
5676            else
5677                msg = msg_note(&snac, content_2, to, in_reply_to, attach_list, scope, post_lang, NULL);
5678
5679            if (sensitive != NULL) {
5680                msg = xs_dict_set(msg, "sensitive", xs_stock(XSTYPE_TRUE));
5681                msg = xs_dict_set(msg, "summary",   xs_is_null(summary) ? "..." : summary);
5682            }
5683
5684            if (xs_is_string(post_date) && *post_date) {
5685                xs *post_pubdate = xs_fmt("%sT%s", post_date,
5686                    xs_is_string(post_time) && *post_time ? post_time : "00:00:00");
5687
5688                time_t t = xs_parse_iso_date(post_pubdate, 0);
5689
5690                if (t != 0) {
5691                    t -= xs_tz_offset(snac.tz);
5692
5693                    xs *iso_date = xs_str_iso_date(t);
5694                    msg = xs_dict_set(msg, "published", iso_date);
5695
5696                    snac_debug(&snac, 1, xs_fmt("Published date: [%s]", iso_date));
5697                }
5698                else
5699                    snac_log(&snac, xs_fmt("Invalid post date: [%s]", post_pubdate));
5700            }
5701
5702            /* is the published date from the future? */
5703            int future_post = 0;
5704            xs *right_now = xs_str_utctime(0, ISO_DATE_SPEC);
5705
5706            if (strcmp(xs_dict_get(msg, "published"), right_now) > 0)
5707                future_post = 1;
5708
5709            if (xs_is_null(edit_id)) {
5710                /* new message */
5711                const char *id = xs_dict_get(msg, "id");
5712
5713                if (store_as_draft) {
5714                    draft_add(&snac, id, msg);
5715                }
5716                else
5717                if (future_post) {
5718                    schedule_add(&snac, id, msg);
5719                }
5720                else {
5721                    c_msg = msg_create(&snac, msg);
5722                    timeline_add(&snac, id, msg);
5723                }
5724            }
5725            else {
5726                /* an edition of a previous message */
5727                xs *p_msg = NULL;
5728
5729                if (valid_status(object_get(edit_id, &p_msg))) {
5730                    /* copy relevant fields from previous version */
5731                    char *fields[] = { "id", "context", "url",
5732                                       "cc", "attributedTo",
5733                                       "to", "inReplyTo", NULL };
5734                    int n;
5735
5736                    for (n = 0; fields[n]; n++) {
5737                        const char *v = xs_dict_get(p_msg, fields[n]);
5738                        msg = xs_dict_set(msg, fields[n], v);
5739                    }
5740
5741                    if (store_as_draft) {
5742                        draft_add(&snac, edit_id, msg);
5743                    }
5744                    else
5745                    if (is_draft(&snac, edit_id)) {
5746                        /* message was previously a draft; it's a create activity */
5747
5748                        /* if the date is from the past, overwrite it with right_now */
5749                        if (strcmp(xs_dict_get(msg, "published"), right_now) < 0) {
5750                            snac_debug(&snac, 1, xs_fmt("setting draft ancient date to %s", right_now));
5751                            msg = xs_dict_set(msg, "published", right_now);
5752                        }
5753
5754                        /* overwrite object */
5755                        object_add_ow(edit_id, msg);
5756
5757                        if (future_post) {
5758                            schedule_add(&snac, edit_id, msg);
5759                        }
5760                        else {
5761                            c_msg = msg_create(&snac, msg);
5762                            timeline_add(&snac, edit_id, msg);
5763                        }
5764
5765                        draft_del(&snac, edit_id);
5766                    }
5767                    else
5768                    if (is_scheduled(&snac, edit_id)) {
5769                        /* editing an scheduled post; just update it */
5770                        schedule_add(&snac, edit_id, msg);
5771                    }
5772                    else {
5773                        /* ignore the (possibly changed) published date */
5774                        msg = xs_dict_set(msg, "published", xs_dict_get(p_msg, "published"));
5775
5776                        /* set the updated field */
5777                        xs *updated = xs_str_utctime(0, ISO_DATE_SPEC);
5778                        msg = xs_dict_set(msg, "updated", updated);
5779
5780                        /* overwrite object, not updating the indexes */
5781                        object_add_ow(edit_id, msg);
5782
5783                        /* index tags */
5784                        tag_index(edit_id, msg);
5785
5786                        /* update message */
5787                        c_msg = msg_update(&snac, msg);
5788                    }
5789                }
5790                else
5791                    snac_log(&snac, xs_fmt("cannot get object '%s' for editing", edit_id));
5792            }
5793
5794            if (c_msg != NULL) {
5795                enqueue_message(&snac, c_msg);
5796                enqueue_webmention(msg);
5797            }
5798
5799            history_del(&snac, "timeline.html_");
5800        }
5801
5802        status = HTTP_STATUS_SEE_OTHER;
5803    }
5804    else
5805    if (p_path && strcmp(p_path, "admin/action") == 0) { /** **/
5806        /* action on an entry */
5807        const char *id     = xs_dict_get(p_vars, "id");
5808        const char *actor  = xs_dict_get(p_vars, "actor");
5809        const char *action = xs_dict_get(p_vars, "action");
5810        const char *group  = xs_dict_get(p_vars, "group");
5811
5812        if (action == NULL)
5813            return HTTP_STATUS_NOT_FOUND;
5814
5815        snac_debug(&snac, 1, xs_fmt("web action '%s' received", action));
5816
5817        status = HTTP_STATUS_SEE_OTHER;
5818
5819        if (strcmp(action, L("EmojiUnreact")) == 0) { /** **/
5820            const char *eid = xs_dict_get(p_vars, "eid");
5821
5822            if (eid != NULL) {
5823                object_user_cache_del(&snac, id, "admire");
5824                xs *n_msg = msg_emoji_unreact(&snac, id, eid);
5825
5826                if (n_msg != NULL)
5827                    enqueue_message(&snac, n_msg);
5828            }
5829        }
5830        else
5831        if (strcmp(action, L("EmojiReact")) == 0) { /** **/
5832            xs *eid = xs_dup(xs_dict_get(p_vars, "eid"));
5833
5834            xs *ret = msg_emoji_init(&snac, id, eid);
5835            /* fails if either invalid or already reacted */
5836            if (!ret) {
5837                object_user_cache_del(&snac, id, "admire");
5838                ret = msg_emoji_unreact(&snac, id, eid);
5839            }
5840            if (!ret)
5841                status = HTTP_STATUS_NOT_FOUND;
5842        }
5843        else
5844        if (strcmp(action, L("Like")) == 0) { /** **/
5845            xs *msg = msg_admiration(&snac, id, "Like");
5846
5847            if (msg != NULL) {
5848                timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, 1, msg);
5849                enqueue_message(&snac, msg);
5850            }
5851        }
5852        else
5853        if (strcmp(action, L("Boost")) == 0) { /** **/
5854            xs *msg = msg_admiration(&snac, id, "Announce");
5855
5856            if (msg != NULL) {
5857                timeline_admire(&snac, xs_dict_get(msg, "object"), snac.actor, 0, msg);
5858                enqueue_message(&snac, msg);
5859            }
5860        }
5861        else
5862        if (strcmp(action, L("Unlike")) == 0) { /** **/
5863            xs *msg = msg_repulsion(&snac, id, "Like");
5864
5865            if (msg != NULL) {
5866                object_user_cache_del(&snac, id, "admire");
5867                enqueue_message(&snac, msg);
5868            }
5869        }
5870        else
5871        if (strcmp(action, L("Unboost")) == 0) { /** **/
5872            xs *msg = msg_repulsion(&snac, id, "Announce");
5873
5874            if (msg != NULL) {
5875                object_user_cache_del(&snac, id, "admire");
5876                enqueue_message(&snac, msg);
5877            }
5878        }
5879        else
5880        if (strcmp(action, L("MUTE")) == 0) { /** **/
5881            mute(&snac, actor);
5882        }
5883        else
5884        if (strcmp(action, L("Unmute")) == 0) { /** **/
5885            unmute(&snac, actor);
5886        }
5887        else
5888        if (strcmp(action, L("Hide")) == 0) { /** **/
5889            if (is_draft(&snac, id))
5890                draft_del(&snac, id);
5891            else
5892            if (is_scheduled(&snac, id))
5893                schedule_del(&snac, id);
5894            else
5895                hide(&snac, id);
5896        }
5897        else
5898        if (strcmp(action, L("Limit")) == 0) { /** **/
5899            limit(&snac, actor);
5900        }
5901        else
5902        if (strcmp(action, L("Unlimit")) == 0) { /** **/
5903            unlimit(&snac, actor);
5904        }
5905        else
5906        if (strcmp(action, L("Follow")) == 0) { /** **/
5907            xs *msg = msg_follow(&snac, actor);
5908
5909            if (msg != NULL) {
5910                /* reload the actor from the message, in may be different */
5911                actor = xs_dict_get(msg, "object");
5912
5913                following_add(&snac, actor, msg);
5914
5915                enqueue_output_by_actor(&snac, msg, actor, 0);
5916            }
5917        }
5918        else
5919        if (strcmp(action, L("Unfollow")) == 0) { /** **/
5920            /* get the following object */
5921            xs *object = NULL;
5922
5923            if (valid_status(following_get(&snac, actor, &object))) {
5924                xs *msg = msg_undo(&snac, xs_dict_get(object, "object"));
5925
5926                following_del(&snac, actor);
5927
5928                enqueue_output_by_actor(&snac, msg, actor, 0);
5929
5930                snac_log(&snac, xs_fmt("unfollowed actor %s", actor));
5931            }
5932            else
5933                snac_log(&snac, xs_fmt("actor is not being followed %s", actor));
5934        }
5935        else
5936        if (strcmp(action, L("Follow Group")) == 0) { /** **/
5937            xs *msg = msg_follow(&snac, group);
5938
5939            if (msg != NULL) {
5940                /* reload the group from the message, in may be different */
5941                group = xs_dict_get(msg, "object");
5942
5943                following_add(&snac, group, msg);
5944
5945                enqueue_output_by_actor(&snac, msg, group, 0);
5946            }
5947        }
5948        else
5949        if (strcmp(action, L("Unfollow Group")) == 0) { /** **/
5950            /* get the following object */
5951            xs *object = NULL;
5952
5953            if (valid_status(following_get(&snac, group, &object))) {
5954                xs *msg = msg_undo(&snac, xs_dict_get(object, "object"));
5955
5956                following_del(&snac, group);
5957
5958                enqueue_output_by_actor(&snac, msg, group, 0);
5959
5960                snac_log(&snac, xs_fmt("unfollowed group %s", group));
5961            }
5962            else
5963                snac_log(&snac, xs_fmt("actor is not being followed %s", actor));
5964        }
5965        else
5966        if (strcmp(action, L("Delete")) == 0) { /** **/
5967            const char *actor_form = xs_dict_get(p_vars, "actor-form");
5968            if (actor_form != NULL) {
5969                /* delete follower */
5970                if (valid_status(follower_del(&snac, actor)))
5971                    snac_log(&snac, xs_fmt("deleted follower %s", actor));
5972                else
5973                    snac_log(&snac, xs_fmt("error deleting follower %s", actor));
5974            }
5975            else {
5976                /* delete an entry */
5977                if (is_msg_mine(&snac, id) && !is_draft(&snac, id)) {
5978                    /* it's a post by us: generate a delete */
5979                    xs *msg = msg_delete(&snac, id);
5980
5981                    enqueue_message(&snac, msg);
5982
5983                    snac_log(&snac, xs_fmt("posted tombstone for %s", id));
5984                }
5985
5986                timeline_del(&snac, id);
5987
5988                draft_del(&snac, id);
5989
5990                schedule_del(&snac, id);
5991
5992                snac_log(&snac, xs_fmt("deleted entry %s", id));
5993            }
5994        }
5995        else
5996        if (strcmp(action, L("Pin")) == 0) { /** **/
5997            pin(&snac, id);
5998            timeline_touch(&snac);
5999        }
6000        else
6001        if (strcmp(action, L("Unpin")) == 0) { /** **/
6002            unpin(&snac, id);
6003            timeline_touch(&snac);
6004        }
6005        else
6006        if (strcmp(action, L("Bookmark")) == 0) { /** **/
6007            bookmark(&snac, id);
6008            timeline_touch(&snac);
6009        }
6010        else
6011        if (strcmp(action, L("Unbookmark")) == 0) { /** **/
6012            unbookmark(&snac, id);
6013            timeline_touch(&snac);
6014        }
6015        else
6016        if (strcmp(action, L("Approve")) == 0) { /** **/
6017            xs *fwreq = pending_get(&snac, actor);
6018
6019            if (fwreq != NULL) {
6020                xs *reply = msg_accept(&snac, fwreq, actor);
6021
6022                enqueue_message(&snac, reply);
6023
6024                if (xs_is_null(xs_dict_get(fwreq, "published"))) {
6025                    /* add a date if it doesn't include one (Mastodon) */
6026                    xs *date = xs_str_utctime(0, ISO_DATE_SPEC);
6027                    fwreq = xs_dict_set(fwreq, "published", date);
6028                }
6029
6030                timeline_add(&snac, xs_dict_get(fwreq, "id"), fwreq);
6031
6032                follower_add(&snac, actor);
6033
6034                pending_del(&snac, actor);
6035
6036                snac_log(&snac, xs_fmt("new follower %s", actor));
6037            }
6038        }
6039        else
6040        if (strcmp(action, L("Discard")) == 0) { /** **/
6041            pending_del(&snac, actor);
6042        }
6043        else
6044            status = HTTP_STATUS_NOT_FOUND;
6045
6046        /* delete the cached timeline */
6047        if (status == HTTP_STATUS_SEE_OTHER)
6048            history_del(&snac, "timeline.html_");
6049    }
6050    else
6051    if (p_path && strcmp(p_path, "admin/user-setup") == 0) { /** **/
6052        /* change of user data */
6053        const char *v;
6054        const char *p1, *p2;
6055
6056        if ((v = xs_dict_get(p_vars, "name")) != NULL)
6057            snac.config = xs_dict_set(snac.config, "name", v);
6058        if ((v = xs_dict_get(p_vars, "avatar")) != NULL)
6059            snac.config = xs_dict_set(snac.config, "avatar", v);
6060        if ((v = xs_dict_get(p_vars, "bio")) != NULL)
6061            snac.config = xs_dict_set(snac.config, "bio", v);
6062        if ((v = xs_dict_get(p_vars, "cw")) != NULL &&
6063            strcmp(v, "on") == 0) {
6064            snac.config = xs_dict_set(snac.config, "cw", "open");
6065        } else { /* if the checkbox is not set, the parameter is missing */
6066            snac.config = xs_dict_set(snac.config, "cw", "");
6067        }
6068        if ((v = xs_dict_get(p_vars, "email")) != NULL)
6069            snac.config = xs_dict_set(snac.config, "email", v);
6070        if ((v = xs_dict_get(p_vars, "telegram_bot")) != NULL)
6071            snac.config = xs_dict_set(snac.config, "telegram_bot", v);
6072        if ((v = xs_dict_get(p_vars, "telegram_chat_id")) != NULL)
6073            snac.config = xs_dict_set(snac.config, "telegram_chat_id", v);
6074        if ((v = xs_dict_get(p_vars, "ntfy_server")) != NULL)
6075            snac.config = xs_dict_set(snac.config, "ntfy_server", v);
6076        if ((v = xs_dict_get(p_vars, "ntfy_token")) != NULL)
6077            snac.config = xs_dict_set(snac.config, "ntfy_token", v);
6078        if ((v = xs_dict_get(p_vars, "purge_days")) != NULL) {
6079            xs *days    = xs_number_new(atof(v));
6080            snac.config = xs_dict_set(snac.config, "purge_days", days);
6081        }
6082        if ((v = xs_dict_get(p_vars, "drop_dm_from_unknown")) != NULL && strcmp(v, "on") == 0)
6083            snac.config = xs_dict_set(snac.config, "drop_dm_from_unknown", xs_stock(XSTYPE_TRUE));
6084        else
6085            snac.config = xs_dict_set(snac.config, "drop_dm_from_unknown", xs_stock(XSTYPE_FALSE));
6086        if ((v = xs_dict_get(p_vars, "bot")) != NULL && strcmp(v, "on") == 0)
6087            snac.config = xs_dict_set(snac.config, "bot", xs_stock(XSTYPE_TRUE));
6088        else
6089            snac.config = xs_dict_set(snac.config, "bot", xs_stock(XSTYPE_FALSE));
6090        if ((v = xs_dict_get(p_vars, "private")) != NULL && strcmp(v, "on") == 0)
6091            snac.config = xs_dict_set(snac.config, "private", xs_stock(XSTYPE_TRUE));
6092        else
6093            snac.config = xs_dict_set(snac.config, "private", xs_stock(XSTYPE_FALSE));
6094        if ((v = xs_dict_get(p_vars, "auto_boost")) != NULL && strcmp(v, "on") == 0)
6095            snac.config = xs_dict_set(snac.config, "auto_boost", xs_stock(XSTYPE_TRUE));
6096        else
6097            snac.config = xs_dict_set(snac.config, "auto_boost", xs_stock(XSTYPE_FALSE));
6098        if ((v = xs_dict_get(p_vars, "collapse_threads")) != NULL && strcmp(v, "on") == 0)
6099            snac.config = xs_dict_set(snac.config, "collapse_threads", xs_stock(XSTYPE_TRUE));
6100        else
6101            snac.config = xs_dict_set(snac.config, "collapse_threads", xs_stock(XSTYPE_FALSE));
6102        if ((v = xs_dict_get(p_vars, "approve_followers")) != NULL && strcmp(v, "on") == 0)
6103            snac.config = xs_dict_set(snac.config, "approve_followers", xs_stock(XSTYPE_TRUE));
6104        else
6105            snac.config = xs_dict_set(snac.config, "approve_followers", xs_stock(XSTYPE_FALSE));
6106        if ((v = xs_dict_get(p_vars, "show_contact_metrics")) != NULL && strcmp(v, "on") == 0)
6107            snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_TRUE));
6108        else
6109            snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_FALSE));
6110        if ((v = xs_dict_get(p_vars, "show_unlisted")) != NULL && strcmp(v, "on") == 0)
6111            snac.config = xs_dict_set(snac.config, "show_unlisted", xs_stock(XSTYPE_TRUE));
6112        else
6113            snac.config = xs_dict_set(snac.config, "show_unlisted", xs_stock(XSTYPE_FALSE));
6114        if ((v = xs_dict_get(p_vars, "web_ui_lang")) != NULL)
6115            snac.config = xs_dict_set(snac.config, "lang", v);
6116        if ((v = xs_dict_get(p_vars, "tz")) != NULL)
6117            snac.config = xs_dict_set(snac.config, "tz", v);
6118        if ((v = xs_dict_get(p_vars, "post_langs")) != NULL)
6119            snac.config = xs_dict_set(snac.config, "post_langs", v);
6120        if ((v = xs_dict_get(p_vars, "excluded_langs")) != NULL)
6121            snac.config = xs_dict_set(snac.config, "excluded_langs", v);
6122        else
6123            snac.config = xs_dict_del(snac.config, "excluded_langs");
6124
6125        snac.config = xs_dict_set(snac.config, "latitude", xs_dict_get_def(p_vars, "latitude", ""));
6126        snac.config = xs_dict_set(snac.config, "longitude", xs_dict_get_def(p_vars, "longitude", ""));
6127
6128        snac.config = xs_dict_set(snac.config, "notify_webhook", xs_dict_get_def(p_vars, "notify_webhook", ""));
6129
6130        if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
6131            snac.config = xs_dict_set(snac.config, "metadata", v);
6132
6133        snac.config = xs_dict_set(snac.config, "text_browser_uas", xs_dict_get_def(p_vars, "text_browser_uas", ""));
6134
6135        /* uploads */
6136        const char *uploads[] = { "avatar", "header", NULL };
6137        int n;
6138        for (n = 0; uploads[n]; n++) {
6139            xs *var_name = xs_fmt("%s_file", uploads[n]);
6140
6141            const xs_list *uploaded_file = xs_dict_get(p_vars, var_name);
6142            if (xs_type(uploaded_file) == XSTYPE_LIST) {
6143                const char *fn = xs_list_get(uploaded_file, 0);
6144
6145                if (fn && *fn) {
6146                    const char *mimetype = xs_mime_by_ext(fn);
6147
6148                    if (xs_startswith(mimetype, "image/")) {
6149                        const char *ext = strrchr(fn, '.');
6150                        xs *hash        = xs_md5_hex(fn, strlen(fn));
6151                        xs *id          = xs_fmt("%s-%s%s", uploads[n], hash, ext ? ext : "");
6152                        xs *url         = xs_fmt("%s/s/%s", snac.actor, id);
6153                        int fo          = xs_number_get(xs_list_get(uploaded_file, 1));
6154                        int fs          = xs_number_get(xs_list_get(uploaded_file, 2));
6155
6156                        /* store */
6157                        static_put(&snac, id, payload + fo, fs);
6158
6159                        snac.config = xs_dict_set(snac.config, uploads[n], url);
6160                    }
6161                }
6162            }
6163        }
6164
6165        /* delete images by removing url from user.json */
6166        for (n = 0; uploads[n]; n++) {
6167            xs *var_name = xs_fmt("%s_delete", uploads[n]);
6168            const char *delete_var = xs_dict_get(p_vars, var_name);
6169
6170            if (delete_var != NULL && strcmp(delete_var, "on") == 0) {
6171                    snac.config = xs_dict_set(snac.config, uploads[n], "");
6172            }
6173        }
6174
6175        /* password change? */
6176        if ((p1 = xs_dict_get(p_vars, "passwd1")) != NULL &&
6177            (p2 = xs_dict_get(p_vars, "passwd2")) != NULL &&
6178            *p1 && strcmp(p1, p2) == 0) {
6179            xs *pw = hash_password(snac.uid, p1, NULL);
6180            snac.config = xs_dict_set(snac.config, "passwd", pw);
6181        }
6182
6183        user_persist(&snac, 1);
6184
6185        status = HTTP_STATUS_SEE_OTHER;
6186    }
6187    else
6188    if (p_path && strcmp(p_path, "admin/filter-notifications") == 0) { /** **/
6189        notify_filter(&snac, p_vars);
6190        user_persist(&snac, 0);
6191        status = HTTP_STATUS_SEE_OTHER;
6192    }
6193    else
6194    if (p_path && strcmp(p_path, "admin/clear-notifications") == 0) { /** **/
6195        notify_clear(&snac);
6196        timeline_touch(&snac);
6197
6198        status = HTTP_STATUS_SEE_OTHER;
6199    }
6200    else
6201    if (p_path && strcmp(p_path, "admin/vote") == 0) { /** **/
6202        const char *irt   = xs_dict_get(p_vars, "irt");
6203        const char *opt   = xs_dict_get(p_vars, "question");
6204        const char *actor = xs_dict_get(p_vars, "actor");
6205
6206        xs *ls = NULL;
6207
6208        /* multiple choices? */
6209        if (xs_type(opt) == XSTYPE_LIST)
6210            ls = xs_dup(opt);
6211        else
6212        if (xs_type(opt) == XSTYPE_STRING) {
6213            ls = xs_list_new();
6214            ls = xs_list_append(ls, opt);
6215        }
6216
6217        const xs_str *v;
6218        int c = 0;
6219
6220        while (xs_list_next(ls, &v, &c)) {
6221            xs *msg = msg_note(&snac, "", actor, irt, NULL, 1, NULL, NULL);
6222
6223            /* set the option */
6224            msg = xs_dict_append(msg, "name", v);
6225
6226            /* delete the content */
6227            msg = xs_dict_del(msg, "content");
6228
6229            xs *c_msg = msg_create(&snac, msg);
6230
6231            enqueue_message(&snac, c_msg);
6232
6233            timeline_add(&snac, xs_dict_get(msg, "id"), msg);
6234        }
6235
6236        if (ls != NULL) {
6237            /* get the poll object */
6238            xs *poll = NULL;
6239
6240            if (valid_status(object_get(irt, &poll))) {
6241                const char *date = xs_dict_get(poll, "endTime");
6242                if (xs_is_null(date))
6243                    date = xs_dict_get(poll, "closed");
6244
6245                if (!xs_is_null(date)) {
6246                    time_t t = xs_parse_iso_date(date, 0) - time(NULL);
6247
6248                    /* request the poll when it's closed;
6249                       Pleroma does not send and update when the poll closes */
6250                    enqueue_object_request(&snac, irt, t + 2);
6251                }
6252            }
6253        }
6254
6255        status = HTTP_STATUS_SEE_OTHER;
6256    }
6257    else
6258    if (p_path && strcmp(p_path, "admin/followed-hashtags") == 0) { /** **/
6259        const char *followed_hashtags = xs_dict_get(p_vars, "followed_hashtags");
6260
6261        if (xs_is_null(followed_hashtags))
6262            followed_hashtags = "";
6263
6264        if (xs_is_string(followed_hashtags)) {
6265            xs *new_hashtags = xs_list_new();
6266            xs *l = xs_split(followed_hashtags, "\n");
6267            const char *v;
6268
6269            xs_list_foreach(l, v) {
6270                xs *s1 = xs_strip_i(xs_dup(v));
6271                s1 = xs_replace_i(s1, " ", "");
6272
6273                if (*s1 == '\0')
6274                    continue;
6275
6276                xs *s2 = NULL;
6277
6278                if (xs_startswith(s1, "https:/"))
6279                    s2 = xs_dup(s1);
6280                else {
6281                    s2 = xs_utf8_to_lower(s1);
6282
6283                    if (*s2 != '#')
6284                        s2 = xs_str_prepend_i(s2, "#");
6285                }
6286
6287                new_hashtags = xs_list_append(new_hashtags, s2);
6288            }
6289
6290            snac.config = xs_dict_set(snac.config, "followed_hashtags", new_hashtags);
6291            user_persist(&snac, 0);
6292        }
6293
6294        status = HTTP_STATUS_SEE_OTHER;
6295    }
6296    else
6297    if (p_path && strcmp(p_path, "admin/blocked-hashtags") == 0) { /** **/
6298        const char *hashtags = xs_dict_get(p_vars, "blocked_hashtags");
6299
6300        if (xs_is_null(hashtags))
6301            hashtags = "";
6302
6303        if (xs_is_string(hashtags)) {
6304            xs *new_hashtags = xs_list_new();
6305            xs *l = xs_split(hashtags, "\n");
6306            const char *v;
6307
6308            xs_list_foreach(l, v) {
6309                xs *s1 = xs_strip_i(xs_dup(v));
6310                s1 = xs_replace_i(s1, " ", "");
6311
6312                if (*s1 == '\0')
6313                    continue;
6314
6315                xs *s2 = xs_utf8_to_lower(s1);
6316                if (*s2 != '#')
6317                    s2 = xs_str_prepend_i(s2, "#");
6318
6319                new_hashtags = xs_list_append(new_hashtags, s2);
6320            }
6321
6322            snac.config = xs_dict_set(snac.config, "blocked_hashtags", new_hashtags);
6323            user_persist(&snac, 0);
6324        }
6325
6326        status = HTTP_STATUS_SEE_OTHER;
6327    }
6328    else
6329    if (p_path && strcmp(p_path, "admin/muted-words") == 0) {
6330        const char *words = xs_dict_get(p_vars, "muted_words");
6331
6332        if (xs_is_string(words)) {
6333            xs *new_words = xs_list_new();
6334            xs *l = xs_split(words, "\n");
6335            const char *v;
6336
6337            xs_list_foreach(l, v) {
6338                xs *s1 = xs_strip_i(xs_dup(v));
6339                s1 = xs_replace_i(s1, " ", "");
6340
6341                if (*s1 == '\0')
6342                    continue;
6343
6344                xs *s2 = xs_utf8_to_lower(s1);
6345
6346                new_words = xs_list_insert_sorted(new_words, s2);
6347            }
6348
6349            snac.config = xs_dict_set(snac.config, "muted_words", new_words);
6350            user_persist(&snac, 0);
6351        }
6352
6353        status = HTTP_STATUS_SEE_OTHER;
6354    }
6355
6356    if (status == HTTP_STATUS_SEE_OTHER) {
6357        const char *hard_redir = xs_dict_get(p_vars, "hard-redir");
6358
6359        if (xs_is_string(hard_redir))
6360            *body = xs_dup(hard_redir);
6361        else {
6362            const char *redir = xs_dict_get(p_vars, "redir");
6363
6364            if (xs_is_null(redir))
6365                redir = "top";
6366
6367            *body = xs_fmt("%s/admin#%s", snac.actor, redir);
6368        }
6369
6370        *b_size = strlen(*body);
6371    }
6372
6373    user_free(&snac);
6374
6375    return status;
6376}