Procházet zdrojové kódy

Merge remote-tracking branch 'upstream/master'

shtrophic před 3 měsíci
rodič
revize
7e743e8918
11 změnil soubory, kde provedl 543 přidání a 76 odebrání
  1. 16 0
      RELEASE_NOTES.md
  2. 80 21
      activitypub.c
  3. 119 0
      data.c
  4. 35 1
      doc/snac.1
  5. 190 40
      html.c
  6. 15 1
      main.c
  7. 25 0
      mastoapi.c
  8. 12 1
      snac.h
  9. 46 7
      utils.c
  10. 4 4
      xs_url.h
  11. 1 1
      xs_version.h

+ 16 - 0
RELEASE_NOTES.md

@@ -1,5 +1,21 @@
 # Release Notes
 
+## UNRELEASED
+
+As many users have asked for it, there is now an option to make the number of followed and following accounts public (still disabled by default). These are only the numbers; the lists themselves are never published.
+
+Some fixes to blocked instances code (posts from them were sometimes shown).
+
+## 2.65
+
+Added a new user option to disable automatic follow confirmations (follow requests must be manually approved from the people page).
+
+The search box also searches for accounts (via webfinger).
+
+New command-line action `import_list`, to import a Mastodon list in CSV format (so that [Mastodon Follow Packs](https://mastodonmigration.wordpress.com/?p=995) can be directly used).
+
+New command-line action `import_block_list`, to import a Mastodon list of accounts to be blocked in CSV format.
+
 ## 2.64
 
 Some tweaks for better integration with https://bsky.brid.gy (the BlueSky bridge by brid.gy).

+ 80 - 21
activitypub.c

@@ -1038,15 +1038,14 @@ xs_dict *msg_base(snac *snac, const char *type, const char *id,
 }
 
 
-xs_dict *msg_collection(snac *snac, const char *id)
+xs_dict *msg_collection(snac *snac, const char *id, int items)
 /* creates an empty OrderedCollection message */
 {
     xs_dict *msg = msg_base(snac, "OrderedCollection", id, NULL, NULL, NULL);
-    xs *ol = xs_list_new();
+    xs *n = xs_number_new(items);
 
     msg = xs_dict_append(msg, "attributedTo", snac->actor);
-    msg = xs_dict_append(msg, "orderedItems", ol);
-    msg = xs_dict_append(msg, "totalItems",   xs_stock(0));
+    msg = xs_dict_append(msg, "totalItems",   n);
 
     return msg;
 }
@@ -1218,7 +1217,30 @@ xs_dict *msg_actor(snac *snac)
     }
 
     /* add the metadata as attachments of PropertyValue */
-    const xs_dict *metadata = xs_dict_get(snac->config, "metadata");
+    xs *metadata = NULL;
+    const xs_dict *md = xs_dict_get(snac->config, "metadata");
+
+    if (xs_type(md) == XSTYPE_DICT)
+        metadata = xs_dup(md);
+    else
+    if (xs_type(md) == XSTYPE_STRING) {
+        metadata = xs_dict_new();
+        xs *l = xs_split(md, "\n");
+        const char *ll;
+
+        xs_list_foreach(l, ll) {
+            xs *kv = xs_split_n(ll, "=", 1);
+            const char *k = xs_list_get(kv, 0);
+            const char *v = xs_list_get(kv, 1);
+
+            if (k && v) {
+                xs *kk = xs_strip_i(xs_dup(k));
+                xs *vv = xs_strip_i(xs_dup(v));
+                metadata = xs_dict_set(metadata, kk, vv);
+            }
+        }
+    }
+
     if (xs_type(metadata) == XSTYPE_DICT) {
         xs *attach = xs_list_new();
         const xs_str *k;
@@ -1264,6 +1286,10 @@ xs_dict *msg_actor(snac *snac)
         msg = xs_dict_set(msg, "alsoKnownAs", loaka);
     }
 
+    const xs_val *manually = xs_dict_get(snac->config, "approve_followers");
+    msg = xs_dict_set(msg, "manuallyApprovesFollowers",
+        xs_stock(xs_is_true(manually) ? XSTYPE_TRUE : XSTYPE_FALSE));
+
     return msg;
 }
 
@@ -1900,22 +1926,31 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
                 object_add(actor, actor_obj);
             }
 
-            xs *f_msg = xs_dup(msg);
-            xs *reply = msg_accept(snac, f_msg, actor);
+            if (xs_is_true(xs_dict_get(snac->config, "approve_followers"))) {
+                pending_add(snac, actor, msg);
 
-            post_message(snac, actor, reply);
-
-            if (xs_is_null(xs_dict_get(f_msg, "published"))) {
-                /* add a date if it doesn't include one (Mastodon) */
-                xs *date = xs_str_utctime(0, ISO_DATE_SPEC);
-                f_msg = xs_dict_set(f_msg, "published", date);
+                snac_log(snac, xs_fmt("new pending follower approval %s", actor));
             }
+            else {
+                /* automatic following */
+                xs *f_msg = xs_dup(msg);
+                xs *reply = msg_accept(snac, f_msg, actor);
+
+                post_message(snac, actor, reply);
+
+                if (xs_is_null(xs_dict_get(f_msg, "published"))) {
+                    /* add a date if it doesn't include one (Mastodon) */
+                    xs *date = xs_str_utctime(0, ISO_DATE_SPEC);
+                    f_msg = xs_dict_set(f_msg, "published", date);
+                }
+
+                timeline_add(snac, id, f_msg);
 
-            timeline_add(snac, id, f_msg);
+                follower_add(snac, actor);
 
-            follower_add(snac, actor);
+                snac_log(snac, xs_fmt("new follower %s", actor));
+            }
 
-            snac_log(snac, xs_fmt("new follower %s", actor));
             do_notify = 1;
         }
         else
@@ -1936,6 +1971,11 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req)
                     snac_log(snac, xs_fmt("no longer following us %s", actor));
                     do_notify = 1;
                 }
+                else
+                if (pending_check(snac, actor)) {
+                    pending_del(snac, actor);
+                    snac_log(snac, xs_fmt("cancelled pending follow from %s", actor));
+                }
                 else
                     snac_log(snac, xs_fmt("error deleting follower %s", actor));
             }
@@ -2796,6 +2836,8 @@ int activitypub_get_handler(const xs_dict *req, const char *q_path,
 
     *ctype  = "application/activity+json";
 
+    int show_contact_metrics = xs_is_true(xs_dict_get(snac.config, "show_contact_metrics"));
+
     if (p_path == NULL) {
         /* if there was no component after the user, it's an actor request */
         msg = msg_actor(&snac);
@@ -2809,7 +2851,6 @@ int activitypub_get_handler(const xs_dict *req, const char *q_path,
     if (strcmp(p_path, "outbox") == 0 || strcmp(p_path, "featured") == 0) {
         xs *id = xs_fmt("%s/%s", snac.actor, p_path);
         xs *list = xs_list_new();
-        msg = msg_collection(&snac, id);
         const char *v;
         int tc = 0;
 
@@ -2831,14 +2872,32 @@ int activitypub_get_handler(const xs_dict *req, const char *q_path,
         }
 
         /* replace the 'orderedItems' with the latest posts */
-        xs *items = xs_number_new(xs_list_len(list));
+        msg = msg_collection(&snac, id, xs_list_len(list));
         msg = xs_dict_set(msg, "orderedItems", list);
-        msg = xs_dict_set(msg, "totalItems",   items);
     }
     else
-    if (strcmp(p_path, "followers") == 0 || strcmp(p_path, "following") == 0) {
+    if (strcmp(p_path, "followers") == 0) {
+        int total = 0;
+
+        if (show_contact_metrics) {
+            xs *l = follower_list(&snac);
+            total = xs_list_len(l);
+        }
+
+        xs *id = xs_fmt("%s/%s", snac.actor, p_path);
+        msg = msg_collection(&snac, id, total);
+    }
+    else
+    if (strcmp(p_path, "following") == 0) {
+        int total = 0;
+
+        if (show_contact_metrics) {
+            xs *l = following_list(&snac);
+            total = xs_list_len(l);
+        }
+
         xs *id = xs_fmt("%s/%s", snac.actor, p_path);
-        msg = msg_collection(&snac, id);
+        msg = msg_collection(&snac, id, total);
     }
     else
     if (xs_startswith(p_path, "p/")) {

+ 119 - 0
data.c

@@ -299,6 +299,35 @@ int user_persist(snac *snac, int publish)
     xs *bfn = xs_fmt("%s.bak", fn);
     FILE *f;
 
+    if (publish) {
+        /* check if any of the relevant fields have really changed */
+        if ((f = fopen(fn, "r")) != NULL) {
+            xs *old = xs_json_load(f);
+            fclose(f);
+
+            if (old != NULL) {
+                int nw = 0;
+                const char *fields[] = { "header", "avatar", "name", "bio", "metadata", NULL };
+
+                for (int n = 0; fields[n]; n++) {
+                    const char *of = xs_dict_get(old, fields[n]);
+                    const char *nf = xs_dict_get(snac->config, fields[n]);
+
+                    if (of == NULL && nf == NULL)
+                        continue;
+
+                    if (xs_type(of) != XSTYPE_STRING || xs_type(nf) != XSTYPE_STRING || strcmp(of, nf)) {
+                        nw = 1;
+                        break;
+                    }
+                }
+
+                if (!nw)
+                    publish = 0;
+            }
+        }
+    }
+
     rename(fn, bfn);
 
     if ((f = fopen(fn, "w")) != NULL) {
@@ -1139,6 +1168,96 @@ xs_list *follower_list(snac *snac)
 }
 
 
+/** pending followers **/
+
+int pending_add(snac *user, const char *actor, const xs_dict *msg)
+/* stores the follow message for later confirmation */
+{
+    xs *dir = xs_fmt("%s/pending", user->basedir);
+    xs *md5 = xs_md5_hex(actor, strlen(actor));
+    xs *fn  = xs_fmt("%s/%s.json", dir, md5);
+    FILE *f;
+
+    mkdirx(dir);
+
+    if ((f = fopen(fn, "w")) == NULL)
+        return -1;
+
+    xs_json_dump(msg, 4, f);
+    fclose(f);
+
+    return 0;
+}
+
+
+int pending_check(snac *user, const char *actor)
+/* checks if there is a pending follow confirmation for the actor */
+{
+    xs *md5 = xs_md5_hex(actor, strlen(actor));
+    xs *fn = xs_fmt("%s/pending/%s.json", user->basedir, md5);
+
+    return mtime(fn) != 0;
+}
+
+
+xs_dict *pending_get(snac *user, const char *actor)
+/* returns the pending follow confirmation for the actor */
+{
+    xs *md5 = xs_md5_hex(actor, strlen(actor));
+    xs *fn = xs_fmt("%s/pending/%s.json", user->basedir, md5);
+    xs_dict *msg = NULL;
+    FILE *f;
+
+    if ((f = fopen(fn, "r")) != NULL) {
+        msg = xs_json_load(f);
+        fclose(f);
+    }
+
+    return msg;
+}
+
+
+void pending_del(snac *user, const char *actor)
+/* deletes a pending follow confirmation for the actor */
+{
+    xs *md5 = xs_md5_hex(actor, strlen(actor));
+    xs *fn = xs_fmt("%s/pending/%s.json", user->basedir, md5);
+
+    unlink(fn);
+}
+
+
+xs_list *pending_list(snac *user)
+/* returns a list of pending follow confirmations */
+{
+    xs *spec = xs_fmt("%s/pending/""*.json", user->basedir);
+    xs *l = xs_glob(spec, 0, 0);
+    xs_list *r = xs_list_new();
+    const char *v;
+
+    xs_list_foreach(l, v) {
+        FILE *f;
+        xs *msg = NULL;
+
+        if ((f = fopen(v, "r")) == NULL)
+            continue;
+
+        msg = xs_json_load(f);
+        fclose(f);
+
+        if (msg == NULL)
+            continue;
+
+        const char *actor = xs_dict_get(msg, "actor");
+
+        if (xs_type(actor) == XSTYPE_STRING)
+            r = xs_list_append(r, actor);
+    }
+
+    return r;
+}
+
+
 /** timeline **/
 
 double timeline_mtime(snac *snac)

+ 35 - 1
doc/snac.1

@@ -129,6 +129,28 @@ Just what it says in the tin. This is to mitigate spammers
 coming from Fediverse instances with lax / open registration
 processes. Please take note that this also avoids possibly
 legitimate people trying to contact you.
+.It This account is a bot
+Set this checkbox if this account behaves like a bot (i.e.
+posts are automatically generated).
+.It Auto-boost all mentions to this account
+If this toggle is set, all mentions to this account are boosted
+to all followers. This can be used to create groups.
+.It This account is private
+If this toggle is set, posts are not published via the public
+web interface, only via the ActivityPub protocol.
+.It Collapse top threads by default
+If this toggle is set, the private timeline will always show
+conversations collapsed by default. This allows easier navigation
+through long threads.
+.It Follow requests must be approved
+If this toggle is set, follow requests are not automatically
+accepted, but notified and stored for later review. Pending
+follow requests will be shown in the people page to be
+approved or discarded.
+.It Publish follower and following metrics
+If this toggle is set, the number of followers and following
+accounts are made public (this is only the number; the specific
+lists of accounts are never published).
 .It Password
 Write the same string in these two fields to change your
 password. Don't write anything if you don't want to do this.
@@ -262,6 +284,13 @@ section 'Migrating from snac to Mastodon').
 Starts a migration from this account to the one set as an alias (see
 .Xr snac 8 ,
 section 'Migrating from snac to Mastodon').
+.It Cm import_csv Ar basedir Ar uid
+Imports CSV data files from a Mastodon export. This command expects the
+following files to be in the current directory:
+.Pa bookmarks.csv ,
+.Pa blocked_accounts.csv ,
+.Pa lists.csv , and
+.Pa following_accounts.csv .
 .It Cm state Ar basedir
 Dumps the current state of the server and its threads. For example:
 .Bd -literal -offset indent
@@ -284,6 +313,11 @@ in-memory job queue. The thread state can be: waiting (idle waiting
 for a job to be assigned), input or output (processing I/O packets)
 or stopped (not running, only to be seen while starting or stopping
 the server).
+.It Cm import_list Ar basedir Ar uid Ar file
+Imports a Mastodon list in CSV format. This option can be used to
+import "Mastodon Follow Packs".
+.It Cm import_block_list Ar basedir Ar uid Ar file
+Imports a Mastodon list of accounts to be blocked in CSV format.
 .El
 .Ss Migrating an account to/from Mastodon
 See 
@@ -349,4 +383,4 @@ See the LICENSE file for details.
 .Sh CAVEATS
 Use the Fediverse sparingly. Don't fear the MUTE button.
 .Sh BUGS
-Probably plenty. Some issues may be even documented in the TODO.md file.
+Probably many. Some issues may be even documented in the TODO.md file.

+ 190 - 40
html.c

@@ -770,7 +770,7 @@ static xs_html *html_user_body(snac *user, int read_only)
                     xs_html_sctag("input",
                         xs_html_attr("type", "text"),
                         xs_html_attr("name", "q"),
-                        xs_html_attr("title", L("Search posts by content (regular expression) or #tag")),
+                        xs_html_attr("title", L("Search posts by content (regular expression), @user@host accounts, or #tag")),
                         xs_html_attr("placeholder", L("Content search")))));
     }
 
@@ -829,21 +829,45 @@ static xs_html *html_user_body(snac *user, int read_only)
     }
 
     if (read_only) {
-        xs *es1  = encode_html(xs_dict_get(user->config, "bio"));
         xs *tags = xs_list_new();
-        xs *bio1 = not_really_markdown(es1, NULL, &tags);
+        xs *bio1 = not_really_markdown(xs_dict_get(user->config, "bio"), NULL, &tags);
         xs *bio2 = process_tags(user, bio1, &tags);
+        xs *bio3 = sanitize(bio2);
 
-        bio2 = replace_shortnames(bio2, tags, 2, proxy);
+        bio3 = replace_shortnames(bio3, tags, 2, proxy);
 
         xs_html *top_user_bio = xs_html_tag("div",
             xs_html_attr("class", "p-note snac-top-user-bio"),
-            xs_html_raw(bio2)); /* already sanitized */
+            xs_html_raw(bio3)); /* already sanitized */
 
         xs_html_add(top_user,
             top_user_bio);
 
-        const xs_dict *metadata = xs_dict_get(user->config, "metadata");
+        xs *metadata = NULL;
+        const xs_dict *md = xs_dict_get(user->config, "metadata");
+
+        if (xs_type(md) == XSTYPE_DICT)
+            metadata = xs_dup(md);
+        else
+        if (xs_type(md) == XSTYPE_STRING) {
+            /* convert to dict for easier iteration */
+            metadata = xs_dict_new();
+            xs *l = xs_split(md, "\n");
+            const char *ll;
+
+            xs_list_foreach(l, ll) {
+                xs *kv = xs_split_n(ll, "=", 1);
+                const char *k = xs_list_get(kv, 0);
+                const char *v = xs_list_get(kv, 1);
+
+                if (k && v) {
+                    xs *kk = xs_strip_i(xs_dup(k));
+                    xs *vv = xs_strip_i(xs_dup(v));
+                    metadata = xs_dict_set(metadata, kk, vv);
+                }
+            }
+        }
+
         if (xs_type(metadata) == XSTYPE_DICT) {
             const xs_str *k;
             const xs_str *v;
@@ -914,6 +938,18 @@ static xs_html *html_user_body(snac *user, int read_only)
             xs_html_add(top_user,
                 snac_metadata);
         }
+
+        if (xs_is_true(xs_dict_get(user->config, "show_contact_metrics"))) {
+            xs *fwers = follower_list(user);
+            xs *fwing = following_list(user);
+
+            xs *s1 = xs_fmt(L("%d following %d followers"),
+                xs_list_len(fwing), xs_list_len(fwers));
+
+            xs_html_add(top_user,
+                xs_html_tag("p",
+                    xs_html_text(s1)));
+        }
     }
 
     xs_html_add(body,
@@ -1025,20 +1061,31 @@ xs_html *html_top_controls(snac *snac)
     const xs_val *a_private = xs_dict_get(snac->config, "private");
     const xs_val *auto_boost = xs_dict_get(snac->config, "auto_boost");
     const xs_val *coll_thrds = xs_dict_get(snac->config, "collapse_threads");
+    const xs_val *pending    = xs_dict_get(snac->config, "approve_followers");
+    const xs_val *show_foll  = xs_dict_get(snac->config, "show_contact_metrics");
 
-    xs *metadata = xs_str_new(NULL);
+    xs *metadata = NULL;
     const xs_dict *md = xs_dict_get(snac->config, "metadata");
-    const xs_str *k;
-    const xs_str *v;
 
-    int c = 0;
-    while (xs_dict_next(md, &k, &v, &c)) {
-        xs *kp = xs_fmt("%s=%s", k, v);
+    if (xs_type(md) == XSTYPE_DICT) {
+        const xs_str *k;
+        const xs_str *v;
 
-        if (*metadata)
-            metadata = xs_str_cat(metadata, "\n");
-        metadata = xs_str_cat(metadata, kp);
+        metadata = xs_str_new(NULL);
+
+        xs_dict_foreach(md, k, v) {
+            xs *kp = xs_fmt("%s=%s", k, v);
+
+            if (*metadata)
+                metadata = xs_str_cat(metadata, "\n");
+            metadata = xs_str_cat(metadata, kp);
+        }
     }
+    else
+    if (xs_type(md) == XSTYPE_STRING)
+        metadata = xs_dup(md);
+    else
+        metadata = xs_str_new(NULL);
 
     xs *user_setup_action = xs_fmt("%s/admin/user-setup", snac->actor);
 
@@ -1187,6 +1234,24 @@ xs_html *html_top_controls(snac *snac)
                     xs_html_tag("label",
                         xs_html_attr("for", "collapse_threads"),
                         xs_html_text(L("Collapse top threads by default")))),
+                xs_html_tag("p",
+                    xs_html_sctag("input",
+                        xs_html_attr("type", "checkbox"),
+                        xs_html_attr("name", "approve_followers"),
+                        xs_html_attr("id",   "approve_followers"),
+                        xs_html_attr(xs_is_true(pending) ? "checked" : "", NULL)),
+                    xs_html_tag("label",
+                        xs_html_attr("for", "approve_followers"),
+                        xs_html_text(L("Follow requests must be approved")))),
+                xs_html_tag("p",
+                    xs_html_sctag("input",
+                        xs_html_attr("type", "checkbox"),
+                        xs_html_attr("name", "show_contact_metrics"),
+                        xs_html_attr("id",   "show_contact_metrics"),
+                        xs_html_attr(xs_is_true(show_foll) ? "checked" : "", NULL)),
+                    xs_html_tag("label",
+                        xs_html_attr("for", "show_contact_metrics"),
+                        xs_html_text(L("Publish follower and following metrics")))),
                 xs_html_tag("p",
                     xs_html_text(L("Profile metadata (key=value pairs in each line):")),
                     xs_html_sctag("br", NULL),
@@ -1481,6 +1546,9 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
     if ((read_only || !user) && !is_msg_public(msg))
         return NULL;
 
+    if (id && is_instance_blocked(id))
+        return NULL;
+
     if (user && level == 0 && xs_is_true(xs_dict_get(user->config, "collapse_threads")))
         collapse_threads = 1;
 
@@ -2437,10 +2505,9 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
                 xs_html_tag("summary",
                     xs_html_text("..."))));
 
-    xs_list *p = list;
     const char *actor_id;
 
-    while (xs_list_iter(&p, &actor_id)) {
+    xs_list_foreach(list, actor_id) {
         xs *md5 = xs_md5_hex(actor_id, strlen(actor_id));
         xs *actor = NULL;
 
@@ -2509,6 +2576,15 @@ xs_html *html_people_list(snac *snac, xs_list *list, char *header, char *t, cons
                         html_button("limit", L("Limit"),
                                 L("Block announces (boosts) from this user")));
             }
+            else
+            if (pending_check(snac, actor_id)) {
+                xs_html_add(form,
+                    html_button("approve", L("Approve"),
+                                L("Approve this follow request")));
+
+                xs_html_add(form,
+                    html_button("discard", L("Discard"), L("Discard this follow request")));
+            }
             else {
                 xs_html_add(form,
                     html_button("follow", L("Follow"),
@@ -2563,13 +2639,23 @@ xs_str *html_people(snac *user)
     xs *wing = following_list(user);
     xs *wers = follower_list(user);
 
+    xs_html *lists = xs_html_tag("div",
+        xs_html_attr("class", "snac-posts"));
+
+    if (xs_is_true(xs_dict_get(user->config, "approve_followers"))) {
+        xs *pending = pending_list(user);
+        xs_html_add(lists,
+            html_people_list(user, pending, L("Pending follow confirmations"), "p", proxy));
+    }
+
+    xs_html_add(lists,
+        html_people_list(user, wing, L("People you follow"), "i", proxy),
+        html_people_list(user, wers, L("People that follow you"), "e", proxy));
+
     xs_html *html = xs_html_tag("html",
         html_user_head(user, NULL, NULL),
         xs_html_add(html_user_body(user, 0),
-            xs_html_tag("div",
-                xs_html_attr("class", "snac-posts"),
-                html_people_list(user, wing, L("People you follow"), "i", proxy),
-                html_people_list(user, wers, L("People that follow you"), "e", proxy)),
+            lists,
             html_footer()));
 
     return xs_html_render_s(html, "<!DOCTYPE html>\n");
@@ -2661,6 +2747,9 @@ xs_str *html_notifications(snac *user, int skip, int show)
                 label = wrk;
             }
         }
+        else
+        if (strcmp(type, "Follow") == 0 && pending_check(user, actor_id))
+            label = L("Follow Request");
 
         xs *s_date = xs_crop_i(xs_dup(date), 0, 10);
 
@@ -2909,6 +2998,48 @@ int html_get_handler(const xs_dict *req, const char *q_path,
             const char *q = xs_dict_get(q_vars, "q");
 
             if (q && *q) {
+                if (xs_regex_match(q, "^@?[a-zA-Z0-9_]+@[a-zA-Z0-9-]+\\.")) {
+                    /** search account **/
+                    xs *actor = NULL;
+                    xs *acct = NULL;
+                    xs *l = xs_list_new();
+                    xs_html *page = NULL;
+
+                    if (valid_status(webfinger_request(q, &actor, &acct))) {
+                        xs *actor_obj = NULL;
+
+                        if (valid_status(actor_request(&snac, actor, &actor_obj))) {
+                            actor_add(actor, actor_obj);
+
+                            /* create a people list with only one element */
+                            l = xs_list_append(xs_list_new(), actor);
+
+                            xs *title = xs_fmt(L("Search results for account %s"), q);
+
+                            page = html_people_list(&snac, l, title, "wf", NULL);
+                        }
+                    }
+
+                    if (page == NULL) {
+                        xs *title = xs_fmt(L("Account %s not found"), q);
+
+                        page = xs_html_tag("div",
+                            xs_html_tag("h2",
+                                xs_html_attr("class", "snac-header"),
+                                xs_html_text(title)));
+                    }
+
+                    xs_html *html = xs_html_tag("html",
+                        html_user_head(&snac, NULL, NULL),
+                        xs_html_add(html_user_body(&snac, 0),
+                        page,
+                        html_footer()));
+
+                    *body = xs_html_render_s(html, "<!DOCTYPE html>\n");
+                    *b_size = strlen(*body);
+                    status = HTTP_STATUS_OK;
+                }
+                else
                 if (*q == '#') {
                     /** search by tag **/
                     xs *tl = tag_search(q, skip, show + 1);
@@ -3646,6 +3777,34 @@ int html_post_handler(const xs_dict *req, const char *q_path,
             unbookmark(&snac, id);
             timeline_touch(&snac);
         }
+        else
+        if (strcmp(action, L("Approve")) == 0) { /** **/
+            xs *fwreq = pending_get(&snac, actor);
+
+            if (fwreq != NULL) {
+                xs *reply = msg_accept(&snac, fwreq, actor);
+
+                enqueue_message(&snac, reply);
+
+                if (xs_is_null(xs_dict_get(fwreq, "published"))) {
+                    /* add a date if it doesn't include one (Mastodon) */
+                    xs *date = xs_str_utctime(0, ISO_DATE_SPEC);
+                    fwreq = xs_dict_set(fwreq, "published", date);
+                }
+
+                timeline_add(&snac, xs_dict_get(fwreq, "id"), fwreq);
+
+                follower_add(&snac, actor);
+
+                pending_del(&snac, actor);
+
+                snac_log(&snac, xs_fmt("new follower %s", actor));
+            }
+        }
+        else
+        if (strcmp(action, L("Discard")) == 0) { /** **/
+            pending_del(&snac, actor);
+        }
         else
             status = HTTP_STATUS_NOT_FOUND;
 
@@ -3705,26 +3864,17 @@ int html_post_handler(const xs_dict *req, const char *q_path,
             snac.config = xs_dict_set(snac.config, "collapse_threads", xs_stock(XSTYPE_TRUE));
         else
             snac.config = xs_dict_set(snac.config, "collapse_threads", xs_stock(XSTYPE_FALSE));
+        if ((v = xs_dict_get(p_vars, "approve_followers")) != NULL && strcmp(v, "on") == 0)
+            snac.config = xs_dict_set(snac.config, "approve_followers", xs_stock(XSTYPE_TRUE));
+        else
+            snac.config = xs_dict_set(snac.config, "approve_followers", xs_stock(XSTYPE_FALSE));
+        if ((v = xs_dict_get(p_vars, "show_contact_metrics")) != NULL && strcmp(v, "on") == 0)
+            snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_TRUE));
+        else
+            snac.config = xs_dict_set(snac.config, "show_contact_metrics", xs_stock(XSTYPE_FALSE));
 
-        if ((v = xs_dict_get(p_vars, "metadata")) != NULL) {
-            /* split the metadata and store it as a dict */
-            xs_dict *md = xs_dict_new();
-            xs *l = xs_split(v, "\n");
-            xs_list *p = l;
-            const xs_str *kp;
-
-            while (xs_list_iter(&p, &kp)) {
-                xs *kpl = xs_split_n(kp, "=", 1);
-                if (xs_list_len(kpl) == 2) {
-                    xs *k2 = xs_strip_i(xs_dup(xs_list_get(kpl, 0)));
-                    xs *v2 = xs_strip_i(xs_dup(xs_list_get(kpl, 1)));
-
-                    md = xs_dict_set(md, k2, v2);
-                }
-            }
-
-            snac.config = xs_dict_set(snac.config, "metadata", md);
-        }
+        if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
+            snac.config = xs_dict_set(snac.config, "metadata", v);
 
         /* uploads */
         const char *uploads[] = { "avatar", "header", NULL };

+ 15 - 1
main.c

@@ -51,7 +51,9 @@ int usage(void)
     printf("export_csv {basedir} {uid}           Exports data as CSV files into current directory\n");
     printf("alias {basedir} {uid} {account}      Sets account (@user@host or actor url) as an alias\n");
     printf("migrate {basedir} {uid}              Migrates to the account defined as the alias\n");
-    printf("import_csv {basedir} {uid}           Imports data from CSV files into current directory\n");
+    printf("import_csv {basedir} {uid}           Imports data from CSV files in the current directory\n");
+    printf("import_list {basedir} {uid} {file}   Imports a Mastodon CSV list file\n");
+    printf("import_block_list {basedir} {uid} {file} Imports a Mastodon CSV block list file\n");
 
     return 1;
 }
@@ -589,6 +591,18 @@ int main(int argc, char *argv[])
         return 0;
     }
 
+    if (strcmp(cmd, "import_list") == 0) { /** **/
+        import_list_csv(&snac, url);
+
+        return 0;
+    }
+
+    if (strcmp(cmd, "import_block_list") == 0) { /** **/
+        import_blocked_accounts_csv(&snac, url);
+
+        return 0;
+    }
+
     if (strcmp(cmd, "note") == 0) { /** **/
         xs *content = NULL;
         xs *msg = NULL;

+ 25 - 0
mastoapi.c

@@ -663,6 +663,17 @@ xs_dict *mastoapi_account(snac *logged, const xs_dict *actor)
         if (user_open(&user, prefu)) {
             val_links = user.links;
             metadata  = xs_dict_get_def(user.config, "metadata", xs_stock(XSTYPE_DICT));
+
+            /* does this user want to publish their contact metrics? */
+            if (xs_is_true(xs_dict_get(user.config, "show_contact_metrics"))) {
+                xs *fwing = following_list(&user);
+                xs *fwers = follower_list(&user);
+                xs *ni = xs_number_new(xs_list_len(fwing));
+                xs *ne = xs_number_new(xs_list_len(fwers));
+
+                acct = xs_dict_append(acct, "followers_count", ne);
+                acct = xs_dict_append(acct, "following_count", ni);
+            }
         }
     }
 
@@ -1275,6 +1286,17 @@ void credentials_get(char **body, char **ctype, int *status, snac snac)
     acct = xs_dict_append(acct, "following_count", xs_stock(0));
     acct = xs_dict_append(acct, "statuses_count", xs_stock(0));
 
+    /* does this user want to publish their contact metrics? */
+    if (xs_is_true(xs_dict_get(snac.config, "show_contact_metrics"))) {
+        xs *fwing = following_list(&snac);
+        xs *fwers = follower_list(&snac);
+        xs *ni = xs_number_new(xs_list_len(fwing));
+        xs *ne = xs_number_new(xs_list_len(fwers));
+
+        acct = xs_dict_append(acct, "followers_count", ne);
+        acct = xs_dict_append(acct, "following_count", ni);
+    }
+
     *body = xs_json_dumps(acct, 4);
     *ctype = "application/json";
     *status = HTTP_STATUS_OK;
@@ -1349,6 +1371,9 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
             if (!xs_match(type, POSTLIKE_OBJECT_TYPE))
                 continue;
 
+            if (id && is_instance_blocked(id))
+                continue;
+
             const char *from = NULL;
             if (strcmp(type, "Page") == 0)
                 from = xs_dict_get(msg, "audience");

+ 12 - 1
snac.h

@@ -1,7 +1,7 @@
 /* snac - A simple, minimalistic ActivityPub instance */
 /* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
 
-#define VERSION "2.64"
+#define VERSION "2.66-dev"
 
 #define USER_AGENT "snac/" VERSION
 
@@ -143,6 +143,12 @@ int follower_del(snac *snac, const char *actor);
 int follower_check(snac *snac, const char *actor);
 xs_list *follower_list(snac *snac);
 
+int pending_add(snac *user, const char *actor, const xs_dict *msg);
+int pending_check(snac *user, const char *actor);
+xs_dict *pending_get(snac *user, const char *actor);
+void pending_del(snac *user, const char *actor);
+xs_list *pending_list(snac *user);
+
 double timeline_mtime(snac *snac);
 int timeline_touch(snac *snac);
 int timeline_here(snac *snac, const char *md5);
@@ -316,6 +322,7 @@ xs_dict *msg_update(snac *snac, const xs_dict *object);
 xs_dict *msg_ping(snac *user, const char *rcpt);
 xs_dict *msg_pong(snac *user, const char *rcpt, const char *object);
 xs_dict *msg_move(snac *user, const char *new_account);
+xs_dict *msg_accept(snac *snac, const xs_val *object, const char *to);
 xs_dict *msg_question(snac *user, const char *content, xs_list *attach,
                       const xs_list *opts, int multiple, int end_secs);
 
@@ -399,6 +406,10 @@ void verify_links(snac *user);
 
 void export_csv(snac *user);
 int migrate_account(snac *user);
+
+void import_blocked_accounts_csv(snac *user, const char *fn);
+void import_following_accounts_csv(snac *user, const char *fn);
+void import_list_csv(snac *user, const char *fn);
 void import_csv(snac *user);
 
 typedef enum {

+ 46 - 7
utils.c

@@ -670,20 +670,18 @@ void export_csv(snac *user)
 }
 
 
-void import_csv(snac *user)
-/* import CSV files from Mastodon */
+void import_blocked_accounts_csv(snac *user, const char *fn)
+/* imports a Mastodon CSV file of blocked accounts */
 {
     FILE *f;
-    const char *fn;
 
-    fn = "blocked_accounts.csv";
     if ((f = fopen(fn, "r")) != NULL) {
         snac_log(user, xs_fmt("Importing from %s...", fn));
 
         while (!feof(f)) {
             xs *l = xs_strip_i(xs_readline(f));
 
-            if (*l) {
+            if (*l && strchr(l, '@') != NULL) {
                 xs *url = NULL;
                 xs *uid = NULL;
 
@@ -704,8 +702,14 @@ void import_csv(snac *user)
     }
     else
         snac_log(user, xs_fmt("Cannot open file %s", fn));
+}
+
+
+void import_following_accounts_csv(snac *user, const char *fn)
+/* imports a Mastodon CSV file of accounts to follow */
+{
+    FILE *f;
 
-    fn = "following_accounts.csv";
     if ((f = fopen(fn, "r")) != NULL) {
         snac_log(user, xs_fmt("Importing from %s...", fn));
 
@@ -757,8 +761,14 @@ void import_csv(snac *user)
     }
     else
         snac_log(user, xs_fmt("Cannot open file %s", fn));
+}
+
+
+void import_list_csv(snac *user, const char *fn)
+/* imports a Mastodon CSV file list */
+{
+    FILE *f;
 
-    fn = "lists.csv";
     if ((f = fopen(fn, "r")) != NULL) {
         snac_log(user, xs_fmt("Importing from %s...", fn));
 
@@ -782,6 +792,21 @@ void import_csv(snac *user)
 
                         list_content(user, list_id, actor_md5, 1);
                         snac_log(user, xs_fmt("Added %s to list %s", url, lname));
+
+                        if (!following_check(user, url)) {
+                            xs *msg = msg_follow(user, url);
+
+                            if (msg == NULL) {
+                                snac_log(user, xs_fmt("Cannot follow %s -- server down?", acct));
+                                continue;
+                            }
+
+                            following_add(user, url, msg);
+
+                            enqueue_output_by_actor(user, msg, url, 0);
+
+                            snac_log(user, xs_fmt("Following %s", url));
+                        }
                     }
                     else
                         snac_log(user, xs_fmt("Webfinger error while adding %s to list %s", acct, lname));
@@ -793,6 +818,20 @@ void import_csv(snac *user)
     }
     else
         snac_log(user, xs_fmt("Cannot open file %s", fn));
+}
+
+
+void import_csv(snac *user)
+/* import CSV files from Mastodon */
+{
+    FILE *f;
+    const char *fn;
+
+    import_blocked_accounts_csv(user, "blocked_accounts.csv");
+
+    import_following_accounts_csv(user, "following_accounts.csv");
+
+    import_list_csv(user, "lists.csv");
 
     fn = "bookmarks.csv";
     if ((f = fopen(fn, "r")) != NULL) {

+ 4 - 4
xs_url.h

@@ -106,13 +106,13 @@ xs_dict *xs_multipart_form_data(const char *payload, int p_size, const char *hea
         if (xs_list_len(l1) != 2)
             return NULL;
 
-        boundary = xs_dup(xs_list_get(l1, 1));
+        xs *t_boundary = xs_dup(xs_list_get(l1, 1));
 
         /* Tokodon sends the boundary header with double quotes surrounded */
-        if (xs_between("\"", boundary, "\"") != 0)
-            boundary = xs_strip_chars_i(boundary, "\"");
+        if (xs_between("\"", t_boundary, "\"") != 0)
+            t_boundary = xs_strip_chars_i(t_boundary, "\"");
 
-        boundary = xs_fmt("--%s", boundary);
+        boundary = xs_fmt("--%s", t_boundary);
     }
 
     bsz = strlen(boundary);

+ 1 - 1
xs_version.h

@@ -1 +1 @@
-/* ab0749f821f1c98d16cbec53201bdf2ba2a24a43 2024-11-20T17:02:42+01:00 */
+/* 297f71e198be7819213e9122e1e78c3b963111bc 2024-11-24T18:48:42+01:00 */