Browse Source

Merge remote-tracking branch 'upstream/master'

shtrophic 4 months ago
parent
commit
7e743e8918
11 changed files with 543 additions and 76 deletions
  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
 # 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
 ## 2.64
 
 
 Some tweaks for better integration with https://bsky.brid.gy (the BlueSky bridge by brid.gy).
 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 */
 /* creates an empty OrderedCollection message */
 {
 {
     xs_dict *msg = msg_base(snac, "OrderedCollection", id, NULL, NULL, NULL);
     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, "attributedTo", snac->actor);
-    msg = xs_dict_append(msg, "orderedItems", ol);
+    msg = xs_dict_append(msg, "totalItems",   n);
-    msg = xs_dict_append(msg, "totalItems",   xs_stock(0));
 
 
     return msg;
     return msg;
 }
 }
@@ -1218,7 +1217,30 @@ xs_dict *msg_actor(snac *snac)
     }
     }
 
 
     /* add the metadata as attachments of PropertyValue */
     /* 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) {
     if (xs_type(metadata) == XSTYPE_DICT) {
         xs *attach = xs_list_new();
         xs *attach = xs_list_new();
         const xs_str *k;
         const xs_str *k;
@@ -1264,6 +1286,10 @@ xs_dict *msg_actor(snac *snac)
         msg = xs_dict_set(msg, "alsoKnownAs", loaka);
         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;
     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);
                 object_add(actor, actor_obj);
             }
             }
 
 
-            xs *f_msg = xs_dup(msg);
+            if (xs_is_true(xs_dict_get(snac->config, "approve_followers"))) {
-            xs *reply = msg_accept(snac, f_msg, actor);
+                pending_add(snac, actor, msg);
 
 
-            post_message(snac, actor, reply);
+                snac_log(snac, xs_fmt("new pending follower approval %s", actor));
-
-            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);
             }
             }
+            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;
             do_notify = 1;
         }
         }
         else
         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));
                     snac_log(snac, xs_fmt("no longer following us %s", actor));
                     do_notify = 1;
                     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
                 else
                     snac_log(snac, xs_fmt("error deleting follower %s", actor));
                     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";
     *ctype  = "application/activity+json";
 
 
+    int show_contact_metrics = xs_is_true(xs_dict_get(snac.config, "show_contact_metrics"));
+
     if (p_path == NULL) {
     if (p_path == NULL) {
         /* if there was no component after the user, it's an actor request */
         /* if there was no component after the user, it's an actor request */
         msg = msg_actor(&snac);
         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) {
     if (strcmp(p_path, "outbox") == 0 || strcmp(p_path, "featured") == 0) {
         xs *id = xs_fmt("%s/%s", snac.actor, p_path);
         xs *id = xs_fmt("%s/%s", snac.actor, p_path);
         xs *list = xs_list_new();
         xs *list = xs_list_new();
-        msg = msg_collection(&snac, id);
         const char *v;
         const char *v;
         int tc = 0;
         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 */
         /* 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, "orderedItems", list);
-        msg = xs_dict_set(msg, "totalItems",   items);
     }
     }
     else
     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);
         xs *id = xs_fmt("%s/%s", snac.actor, p_path);
-        msg = msg_collection(&snac, id);
+        msg = msg_collection(&snac, id, total);
     }
     }
     else
     else
     if (xs_startswith(p_path, "p/")) {
     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);
     xs *bfn = xs_fmt("%s.bak", fn);
     FILE *f;
     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);
     rename(fn, bfn);
 
 
     if ((f = fopen(fn, "w")) != NULL) {
     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 **/
 /** timeline **/
 
 
 double timeline_mtime(snac *snac)
 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
 coming from Fediverse instances with lax / open registration
 processes. Please take note that this also avoids possibly
 processes. Please take note that this also avoids possibly
 legitimate people trying to contact you.
 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
 .It Password
 Write the same string in these two fields to change your
 Write the same string in these two fields to change your
 password. Don't write anything if you don't want to do this.
 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
 Starts a migration from this account to the one set as an alias (see
 .Xr snac 8 ,
 .Xr snac 8 ,
 section 'Migrating from snac to Mastodon').
 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
 .It Cm state Ar basedir
 Dumps the current state of the server and its threads. For example:
 Dumps the current state of the server and its threads. For example:
 .Bd -literal -offset indent
 .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)
 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
 or stopped (not running, only to be seen while starting or stopping
 the server).
 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
 .El
 .Ss Migrating an account to/from Mastodon
 .Ss Migrating an account to/from Mastodon
 See 
 See 
@@ -349,4 +383,4 @@ See the LICENSE file for details.
 .Sh CAVEATS
 .Sh CAVEATS
 Use the Fediverse sparingly. Don't fear the MUTE button.
 Use the Fediverse sparingly. Don't fear the MUTE button.
 .Sh BUGS
 .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_sctag("input",
                         xs_html_attr("type", "text"),
                         xs_html_attr("type", "text"),
                         xs_html_attr("name", "q"),
                         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")))));
                         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) {
     if (read_only) {
-        xs *es1  = encode_html(xs_dict_get(user->config, "bio"));
         xs *tags = xs_list_new();
         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 *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 *top_user_bio = xs_html_tag("div",
             xs_html_attr("class", "p-note snac-top-user-bio"),
             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,
         xs_html_add(top_user,
             top_user_bio);
             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) {
         if (xs_type(metadata) == XSTYPE_DICT) {
             const xs_str *k;
             const xs_str *k;
             const xs_str *v;
             const xs_str *v;
@@ -914,6 +938,18 @@ static xs_html *html_user_body(snac *user, int read_only)
             xs_html_add(top_user,
             xs_html_add(top_user,
                 snac_metadata);
                 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,
     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 *a_private = xs_dict_get(snac->config, "private");
     const xs_val *auto_boost = xs_dict_get(snac->config, "auto_boost");
     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 *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_dict *md = xs_dict_get(snac->config, "metadata");
-    const xs_str *k;
-    const xs_str *v;
 
 
-    int c = 0;
+    if (xs_type(md) == XSTYPE_DICT) {
-    while (xs_dict_next(md, &k, &v, &c)) {
+        const xs_str *k;
-        xs *kp = xs_fmt("%s=%s", k, v);
+        const xs_str *v;
 
 
-        if (*metadata)
+        metadata = xs_str_new(NULL);
-            metadata = xs_str_cat(metadata, "\n");
+
-        metadata = xs_str_cat(metadata, kp);
+        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);
     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_tag("label",
                         xs_html_attr("for", "collapse_threads"),
                         xs_html_attr("for", "collapse_threads"),
                         xs_html_text(L("Collapse top threads by default")))),
                         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_tag("p",
                     xs_html_text(L("Profile metadata (key=value pairs in each line):")),
                     xs_html_text(L("Profile metadata (key=value pairs in each line):")),
                     xs_html_sctag("br", NULL),
                     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))
     if ((read_only || !user) && !is_msg_public(msg))
         return NULL;
         return NULL;
 
 
+    if (id && is_instance_blocked(id))
+        return NULL;
+
     if (user && level == 0 && xs_is_true(xs_dict_get(user->config, "collapse_threads")))
     if (user && level == 0 && xs_is_true(xs_dict_get(user->config, "collapse_threads")))
         collapse_threads = 1;
         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_tag("summary",
                     xs_html_text("..."))));
                     xs_html_text("..."))));
 
 
-    xs_list *p = list;
     const char *actor_id;
     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 *md5 = xs_md5_hex(actor_id, strlen(actor_id));
         xs *actor = NULL;
         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"),
                         html_button("limit", L("Limit"),
                                 L("Block announces (boosts) from this user")));
                                 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 {
             else {
                 xs_html_add(form,
                 xs_html_add(form,
                     html_button("follow", L("Follow"),
                     html_button("follow", L("Follow"),
@@ -2563,13 +2639,23 @@ xs_str *html_people(snac *user)
     xs *wing = following_list(user);
     xs *wing = following_list(user);
     xs *wers = follower_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",
     xs_html *html = xs_html_tag("html",
         html_user_head(user, NULL, NULL),
         html_user_head(user, NULL, NULL),
         xs_html_add(html_user_body(user, 0),
         xs_html_add(html_user_body(user, 0),
-            xs_html_tag("div",
+            lists,
-                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)),
             html_footer()));
             html_footer()));
 
 
     return xs_html_render_s(html, "<!DOCTYPE html>\n");
     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;
                 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);
         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");
             const char *q = xs_dict_get(q_vars, "q");
 
 
             if (q && *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 == '#') {
                 if (*q == '#') {
                     /** search by tag **/
                     /** search by tag **/
                     xs *tl = tag_search(q, skip, show + 1);
                     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);
             unbookmark(&snac, id);
             timeline_touch(&snac);
             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
         else
             status = HTTP_STATUS_NOT_FOUND;
             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));
             snac.config = xs_dict_set(snac.config, "collapse_threads", xs_stock(XSTYPE_TRUE));
         else
         else
             snac.config = xs_dict_set(snac.config, "collapse_threads", xs_stock(XSTYPE_FALSE));
             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) {
+        if ((v = xs_dict_get(p_vars, "metadata")) != NULL)
-            /* split the metadata and store it as a dict */
+            snac.config = xs_dict_set(snac.config, "metadata", v);
-            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);
-        }
 
 
         /* uploads */
         /* uploads */
         const char *uploads[] = { "avatar", "header", NULL };
         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("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("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("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;
     return 1;
 }
 }
@@ -589,6 +591,18 @@ int main(int argc, char *argv[])
         return 0;
         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) { /** **/
     if (strcmp(cmd, "note") == 0) { /** **/
         xs *content = NULL;
         xs *content = NULL;
         xs *msg = 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)) {
         if (user_open(&user, prefu)) {
             val_links = user.links;
             val_links = user.links;
             metadata  = xs_dict_get_def(user.config, "metadata", xs_stock(XSTYPE_DICT));
             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, "following_count", xs_stock(0));
     acct = xs_dict_append(acct, "statuses_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);
     *body = xs_json_dumps(acct, 4);
     *ctype = "application/json";
     *ctype = "application/json";
     *status = HTTP_STATUS_OK;
     *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))
             if (!xs_match(type, POSTLIKE_OBJECT_TYPE))
                 continue;
                 continue;
 
 
+            if (id && is_instance_blocked(id))
+                continue;
+
             const char *from = NULL;
             const char *from = NULL;
             if (strcmp(type, "Page") == 0)
             if (strcmp(type, "Page") == 0)
                 from = xs_dict_get(msg, "audience");
                 from = xs_dict_get(msg, "audience");

+ 12 - 1
snac.h

@@ -1,7 +1,7 @@
 /* snac - A simple, minimalistic ActivityPub instance */
 /* snac - A simple, minimalistic ActivityPub instance */
 /* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
 /* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
 
 
-#define VERSION "2.64"
+#define VERSION "2.66-dev"
 
 
 #define USER_AGENT "snac/" VERSION
 #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);
 int follower_check(snac *snac, const char *actor);
 xs_list *follower_list(snac *snac);
 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);
 double timeline_mtime(snac *snac);
 int timeline_touch(snac *snac);
 int timeline_touch(snac *snac);
 int timeline_here(snac *snac, const char *md5);
 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_ping(snac *user, const char *rcpt);
 xs_dict *msg_pong(snac *user, const char *rcpt, const char *object);
 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_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,
 xs_dict *msg_question(snac *user, const char *content, xs_list *attach,
                       const xs_list *opts, int multiple, int end_secs);
                       const xs_list *opts, int multiple, int end_secs);
 
 
@@ -399,6 +406,10 @@ void verify_links(snac *user);
 
 
 void export_csv(snac *user);
 void export_csv(snac *user);
 int migrate_account(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);
 void import_csv(snac *user);
 
 
 typedef enum {
 typedef enum {

+ 46 - 7
utils.c

@@ -670,20 +670,18 @@ void export_csv(snac *user)
 }
 }
 
 
 
 
-void import_csv(snac *user)
+void import_blocked_accounts_csv(snac *user, const char *fn)
-/* import CSV files from Mastodon */
+/* imports a Mastodon CSV file of blocked accounts */
 {
 {
     FILE *f;
     FILE *f;
-    const char *fn;
 
 
-    fn = "blocked_accounts.csv";
     if ((f = fopen(fn, "r")) != NULL) {
     if ((f = fopen(fn, "r")) != NULL) {
         snac_log(user, xs_fmt("Importing from %s...", fn));
         snac_log(user, xs_fmt("Importing from %s...", fn));
 
 
         while (!feof(f)) {
         while (!feof(f)) {
             xs *l = xs_strip_i(xs_readline(f));
             xs *l = xs_strip_i(xs_readline(f));
 
 
-            if (*l) {
+            if (*l && strchr(l, '@') != NULL) {
                 xs *url = NULL;
                 xs *url = NULL;
                 xs *uid = NULL;
                 xs *uid = NULL;
 
 
@@ -704,8 +702,14 @@ void import_csv(snac *user)
     }
     }
     else
     else
         snac_log(user, xs_fmt("Cannot open file %s", fn));
         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) {
     if ((f = fopen(fn, "r")) != NULL) {
         snac_log(user, xs_fmt("Importing from %s...", fn));
         snac_log(user, xs_fmt("Importing from %s...", fn));
 
 
@@ -757,8 +761,14 @@ void import_csv(snac *user)
     }
     }
     else
     else
         snac_log(user, xs_fmt("Cannot open file %s", fn));
         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) {
     if ((f = fopen(fn, "r")) != NULL) {
         snac_log(user, xs_fmt("Importing from %s...", fn));
         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);
                         list_content(user, list_id, actor_md5, 1);
                         snac_log(user, xs_fmt("Added %s to list %s", url, lname));
                         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
                     else
                         snac_log(user, xs_fmt("Webfinger error while adding %s to list %s", acct, lname));
                         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
     else
         snac_log(user, xs_fmt("Cannot open file %s", fn));
         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";
     fn = "bookmarks.csv";
     if ((f = fopen(fn, "r")) != NULL) {
     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)
         if (xs_list_len(l1) != 2)
             return NULL;
             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 */
         /* Tokodon sends the boundary header with double quotes surrounded */
-        if (xs_between("\"", boundary, "\"") != 0)
+        if (xs_between("\"", t_boundary, "\"") != 0)
-            boundary = xs_strip_chars_i(boundary, "\"");
+            t_boundary = xs_strip_chars_i(t_boundary, "\"");
 
 
-        boundary = xs_fmt("--%s", boundary);
+        boundary = xs_fmt("--%s", t_boundary);
     }
     }
 
 
     bsz = strlen(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 */