diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f46f958..2218c20 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,33 @@ # 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). + +A corner case bug in the media proxying code has been fixed. + +Hashtags can now include underscores. + +The server now creates a pidfile inside the data directory. + +Mastodon API: fixed a crash in the notification code, fixed autocapitalization in the OAuth login field (contributed by fkooman). + ## 2.63 The server can now act as a proxy for all image, audio or video media coming from other account's posts (both from the Web UI and the Mastodon API). This way, other servers will see media requests coming from the server IP, not the user's, improving privacy. This is controlled by setting the `proxy_media` boolean field to `server.json` to true. diff --git a/TODO.md b/TODO.md index 619364c..0f2cbd0 100644 --- a/TODO.md +++ b/TODO.md @@ -16,10 +16,6 @@ Important: deleting a follower should do more that just delete the object, see h ## Wishlist -Implement Proxying for Media Links to Enhance User Privacy (see https://codeberg.org/grunfink/snac2/issues/219 for more information). - -Consider showing only posts by the account owner (not full trees) (see https://codeberg.org/grunfink/snac2/issues/217 for more information). - Add support for subscribing and posting to relays (see https://codeberg.org/grunfink/snac2/issues/216 for more information). The instance timeline should also show boosts from users. @@ -357,3 +353,13 @@ Fix a crash when posting from the links browser (2.63, 2024-11-08T15:57:25+0100) Fix some repeated images in Lemmy posts (2.63, 2024-11-08T15:57:25+0100). Fix a crash when posting an image from the tooot mobile app (2.63, 2024-11-11T19:42:11+0100). + +Fix some URL proxying (2.64, 2024-11-16T07:26:23+0100). + +Allow underscores in hashtags (2.64, 2024-11-16T07:26:23+0100). + +Add a pidfile (2.64, 2024-11-17T10:21:29+0100). + +Implement Proxying for Media Links to Enhance User Privacy (see https://codeberg.org/grunfink/snac2/issues/219 for more information) (2024-11-18T20:36:39+0100). + +Consider showing only posts by the account owner (not full trees) (see https://codeberg.org/grunfink/snac2/issues/217 for more information) (2024-11-18T20:36:39+0100). diff --git a/activitypub.c b/activitypub.c index 0b2fc6a..773df78 100644 --- a/activitypub.c +++ b/activitypub.c @@ -183,6 +183,18 @@ const char *get_atto(const xs_dict *msg) } +const char *get_in_reply_to(const xs_dict *msg) +/* gets the inReplyTo id */ +{ + const xs_val *in_reply_to = xs_dict_get(msg, "inReplyTo"); + + if (xs_type(in_reply_to) == XSTYPE_DICT) + in_reply_to = xs_dict_get(in_reply_to, "id"); + + return in_reply_to; +} + + xs_list *get_attachments(const xs_dict *msg) /* unify the garbage fire that are the attachments */ { @@ -373,7 +385,7 @@ int timeline_request(snac *snac, const char **id, xs_str **wrk, int level) } /* does it have an ancestor? */ - const char *in_reply_to = xs_dict_get(object, "inReplyTo"); + const char *in_reply_to = get_in_reply_to(object); /* store */ timeline_add(snac, nid, object); @@ -671,7 +683,7 @@ int is_msg_for_me(snac *snac, const xs_dict *c_msg) return 3; /* is this message a reply to another? */ - const char *irt = xs_dict_get(msg, "inReplyTo"); + const char *irt = get_in_reply_to(msg); if (!xs_is_null(irt)) { xs *r_msg = NULL; @@ -724,7 +736,7 @@ xs_str *process_tags(snac *snac, const char *content, xs_list **tag) /* use this same server */ def_srv = xs_dup(xs_dict_get(srv_config, "host")); - split = xs_regex_split(content, "(@[A-Za-z0-9_]+(@[A-Za-z0-9\\.-]+)?|[0-9]+;|#[^[:punct:][:space:]]+)"); + split = xs_regex_split(content, "(@[A-Za-z0-9_]+(@[A-Za-z0-9\\.-]+)?|[0-9]+;|#(_|[^[:punct:][:space:]])+)"); p = split; while (xs_list_iter(&p, &v)) { @@ -1026,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; } @@ -1206,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; @@ -1252,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; } @@ -1888,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); + 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); - 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); + 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); + + follower_add(snac, actor); + + snac_log(snac, xs_fmt("new follower %s", actor)); } - timeline_add(snac, id, f_msg); - - follower_add(snac, actor); - - snac_log(snac, xs_fmt("new follower %s", actor)); do_notify = 1; } else @@ -1924,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)); } @@ -1957,7 +2009,7 @@ int process_input_message(snac *snac, const xs_dict *msg, const xs_dict *req) if (xs_match(utype, "Note|Article")) { /** **/ const char *id = xs_dict_get(object, "id"); - const char *in_reply_to = xs_dict_get(object, "inReplyTo"); + const char *in_reply_to = get_in_reply_to(object); const char *atto = get_atto(object); xs *wrk = NULL; @@ -2784,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); @@ -2797,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; @@ -2819,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); + 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, total); } else if (xs_startswith(p_path, "p/")) { diff --git a/data.c b/data.c index 1cd69a5..4e5851a 100644 --- a/data.c +++ b/data.c @@ -336,6 +336,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) { @@ -799,7 +828,7 @@ int _object_add(const char *id, const xs_dict *obj, int ow) fclose(f); /* does this object has a parent? */ - const char *in_reply_to = xs_dict_get(obj, "inReplyTo"); + const char *in_reply_to = get_in_reply_to(obj); if (!xs_is_null(in_reply_to) && *in_reply_to) { /* update the children index of the parent */ @@ -1176,6 +1205,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) diff --git a/doc/snac.1 b/doc/snac.1 index 4c40ac9..efba67e 100644 --- a/doc/snac.1 +++ b/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. diff --git a/doc/snac.5 b/doc/snac.5 index 1b28d25..0168430 100644 --- a/doc/snac.5 +++ b/doc/snac.5 @@ -209,6 +209,8 @@ web interface. .It Pa history/ This directory contains generated HTML files. They may be snapshots of the local timeline in previous months or other cached data. +.It Pa server.pid +This file stores the server PID in a single text line. .El .Sh SEE ALSO .Xr snac 1 , diff --git a/html.c b/html.c index 3a2b14f..edb7e1e 100644 --- a/html.c +++ b/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; @@ -1670,7 +1738,7 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only, if (strcmp(type, "Note") == 0) { if (level == 0) { /* is the parent not here? */ - const char *parent = xs_dict_get(msg, "inReplyTo"); + const char *parent = get_in_reply_to(msg); if (user && !xs_is_null(parent) && *parent && !timeline_here(user, parent)) { xs_html_add(post_header, @@ -2329,7 +2397,7 @@ xs_str *html_timeline(snac *user, const xs_list *list, int read_only, /* is this message a non-public reply? */ if (user != NULL && !is_msg_public(msg)) { - const char *irt = xs_dict_get(msg, "inReplyTo"); + const char *irt = get_in_reply_to(msg); /* is it a reply to something not in the storage? */ if (!xs_is_null(irt) && !object_here(irt)) { @@ -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, "\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, "\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 }; diff --git a/httpd.c b/httpd.c index 1613e1f..81d2f9e 100644 --- a/httpd.c +++ b/httpd.c @@ -774,6 +774,7 @@ void httpd(void) xs *sem_name = NULL; xs *shm_name = NULL; sem_t anon_job_sem; + xs *pidfile = xs_fmt("%s/server.pid", srv_basedir); address = xs_dict_get(srv_config, "address"); @@ -809,6 +810,17 @@ void httpd(void) srv_log(xs_fmt("httpd%s start %s %s", p_state->use_fcgi ? " (FastCGI)" : "", full_address, USER_AGENT)); + { + FILE *f; + + if ((f = fopen(pidfile, "w")) != NULL) { + fprintf(f, "%d\n", getpid()); + fclose(f); + } + else + srv_log(xs_fmt("Cannot create %s: %s", pidfile, strerror(errno))); + } + /* show the number of usable file descriptors */ struct rlimit r; getrlimit(RLIMIT_NOFILE, &r); @@ -894,4 +906,6 @@ void httpd(void) srv_log(xs_fmt("httpd%s stop %s (run time: %s)", p_state->use_fcgi ? " (FastCGI)" : "", full_address, uptime)); + + unlink(pidfile); } diff --git a/main.c b/main.c index c6fff5f..76a7961 100644 --- a/main.c +++ b/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; } @@ -558,7 +560,11 @@ int main(int argc, char *argv[]) if (data != NULL) { xs_json_dump(data, 4, stdout); enqueue_actor_refresh(&snac, xs_dict_get(data, "attributedTo"), 0); - timeline_add(&snac, url, data); + + if (!timeline_here(&snac, url)) + timeline_add(&snac, url, data); + else + printf("Post %s already here\n", url); } return 0; @@ -585,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; diff --git a/mastoapi.c b/mastoapi.c index c9d71b9..990898b 100644 --- a/mastoapi.c +++ b/mastoapi.c @@ -171,7 +171,7 @@ const char *login_page = "" "