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