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