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