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