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