Procházet zdrojové kódy

Merge tag '2.67'

Version 2.67 RELEASED.
shtrophic před 3 měsíci
rodič
revize
a7ca4007f2
13 změnil soubory, kde provedl 692 přidání a 114 odebrání
  1. 4 4
      Makefile
  2. 14 0
      RELEASE_NOTES.md
  3. 24 9
      activitypub.c
  4. 190 0
      data.c
  5. 29 0
      doc/snac.8
  6. 77 0
      examples/static-linking-with-musl.md
  7. 38 14
      format.c
  8. 86 4
      html.c
  9. 24 12
      httpd.c
  10. 192 67
      mastoapi.c
  11. 8 2
      snac.h
  12. 5 1
      webfinger.c
  13. 1 1
      xs_fcgi.h

+ 4 - 4
Makefile

@@ -1,4 +1,4 @@
-PREFIX=/usr/local
+PREFIX?=/usr/local
 PREFIX_MAN=$(PREFIX)/man
 CFLAGS?=-g -Wall -Wextra -pedantic
 
@@ -6,16 +6,16 @@ all: snac
 
 snac: snac.o main.o sandbox.o data.o http.o httpd.o webfinger.o \
     activitypub.o html.o utils.o format.o upgrade.o mastoapi.o
-	$(CC) $(CFLAGS) -L/usr/local/lib *.o -lcurl -lcrypto $(LDFLAGS) -pthread -o $@
+	$(CC) $(CFLAGS) -L$(PREFIX)/lib *.o -lcurl -lcrypto $(LDFLAGS) -pthread -o $@
 
 .c.o:
-	$(CC) $(CFLAGS) $(CPPFLAGS) -I/usr/local/include -c $<
+	$(CC) $(CFLAGS) $(CPPFLAGS) -I$(PREFIX)/include -c $<
 
 clean:
 	rm -rf *.o *.core snac makefile.depend
 
 dep:
-	$(CC) -I/usr/local/include -MM *.c > makefile.depend
+	$(CC) -I$(PREFIX)/include -MM *.c > makefile.depend
 
 install:
 	mkdir -p -m 755 $(PREFIX)/bin

+ 14 - 0
RELEASE_NOTES.md

@@ -1,5 +1,19 @@
 # Release Notes
 
+## 2.67
+
+The search box also accepts post URLs; the post is requested and, if it's found, can be interacted with (liked, boosted, replied to, etc.).
+
+IP addresses for failed logins are tracked and throttled to mitigate brute force attacks (see `snac(8)` for more information).
+
+Fixed a bug regarding repeated attachments when editing a post.
+
+Mastodon API: Improved timeline pagination in some clients (details: an HTTP `Link` header has been added), fixed missing audio attachments, fixed an incorrect value for the `bot` field in newly created accounts, fixed a crash, implemented markers (contributed by nowster).
+
+When running in server mode, the pidfile is locked to avoid concurrent running of the same server instance.
+
+Added documentation and some tweaks to enable static compilation with musl (contributed by Shamar).
+
 ## 2.66
 
 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.

+ 24 - 9
activitypub.c

@@ -258,6 +258,10 @@ xs_list *get_attachments(const xs_dict *msg)
             d = xs_dict_append(d, "href", href);
             d = xs_dict_append(d, "name", name);
 
+            const xs_dict *icon = xs_dict_get(v, "icon");
+            if (xs_type(icon) == XSTYPE_DICT)
+                d = xs_dict_append(d, "icon", icon);
+
             l = xs_list_append(l, d);
         }
     }
@@ -1476,20 +1480,31 @@ xs_dict *msg_note(snac *snac, const xs_str *content, const xs_val *rcpts,
 
     /* create the attachment list, if there are any */
     if (!xs_is_null(attach)) {
-        int c = 0;
-        while (xs_list_next(attach, &v, &c)) {
-            xs *d            = xs_dict_new();
+        xs_list_foreach(attach, v) {
             const char *url  = xs_list_get(v, 0);
             const char *alt  = xs_list_get(v, 1);
             const char *mime = xs_mime_by_ext(url);
+            int add = 1;
+
+            /* check if it's already here */
+            const xs_dict *ad;
+            xs_list_foreach(atls, ad) {
+                if (strcmp(xs_dict_get_def(ad, "url", ""), url) == 0) {
+                    add = 0;
+                    break;
+                }
+            }
 
-            d = xs_dict_append(d, "mediaType", mime);
-            d = xs_dict_append(d, "url",       url);
-            d = xs_dict_append(d, "name",      alt);
-            d = xs_dict_append(d, "type",
-                xs_startswith(mime, "image/") ? "Image" : "Document");
+            if (add) {
+                xs *d = xs_dict_new();
+                d = xs_dict_append(d, "mediaType", mime);
+                d = xs_dict_append(d, "url",       url);
+                d = xs_dict_append(d, "name",      alt);
+                d = xs_dict_append(d, "type",
+                    xs_startswith(mime, "image/") ? "Image" : "Document");
 
-            atls = xs_list_append(atls, d);
+                atls = xs_list_append(atls, d);
+            }
         }
     }
 

+ 190 - 0
data.c

@@ -2705,6 +2705,23 @@ xs_list *content_search(snac *user, const char *regex,
         if (id == NULL || is_hidden(user, id))
             continue;
 
+        /* test for the post URL */
+        if (strcmp(id, regex) == 0) {
+            if (xs_set_add(&seen, md5) == 1)
+                show--;
+
+            continue;
+        }
+
+        /* test for the alternate post id */
+        const char *url = xs_dict_get(post, "url");
+        if (xs_type(url) == XSTYPE_STRING && strcmp(url, regex) == 0) {
+            if (xs_set_add(&seen, md5) == 1)
+                show--;
+
+            continue;
+        }
+
         xs *c = xs_str_new(NULL);
         const char *content = xs_dict_get(post, "content");
         const char *name    = xs_dict_get(post, "name");
@@ -2786,6 +2803,74 @@ xs_str *notify_check_time(snac *snac, int reset)
     return t;
 }
 
+xs_dict *markers_get(snac *snac, const xs_list *markers)
+{
+    xs *data = NULL;
+    xs_dict *returns = xs_dict_new();
+    xs *fn = xs_fmt("%s/markers.json", snac->basedir);
+    const xs_str *v = NULL;
+    FILE *f;
+
+    if ((f = fopen(fn, "r")) != NULL) {
+        data = xs_json_load(f);
+        fclose(f);
+    }
+
+    if (xs_is_null(data))
+        data = xs_dict_new();
+
+    xs_list_foreach(markers, v) {
+        const xs_dict *mark = xs_dict_get(data, v);
+        if (!xs_is_null(mark)) {
+            returns = xs_dict_append(returns, v, mark);
+        }
+    }
+    return returns;
+}
+
+xs_dict *markers_set(snac *snac, const char *home_marker, const char *notify_marker)
+/* gets or sets notification marker */
+{
+    xs *data = NULL;
+    xs_dict *written = xs_dict_new();
+    xs *fn = xs_fmt("%s/markers.json", snac->basedir);
+    FILE *f;
+
+    if ((f = fopen(fn, "r")) != NULL) {
+        data = xs_json_load(f);
+        fclose(f);
+    }
+
+    if (xs_is_null(data))
+        data = xs_dict_new();
+
+    if (!xs_is_null(home_marker)) {
+        xs *home = xs_dict_new();
+        xs *s_tid = tid(0);
+        home = xs_dict_append(home, "last_read_id", home_marker);
+        home = xs_dict_append(home, "version", xs_stock(0));
+        home = xs_dict_append(home, "updated_at", s_tid);
+        data = xs_dict_set(data, "home", home);
+        written = xs_dict_append(written, "home", home);
+    }
+
+    if (!xs_is_null(notify_marker)) {
+        xs *notify = xs_dict_new();
+        xs *s_tid = tid(0);
+        notify = xs_dict_append(notify, "last_read_id", notify_marker);
+        notify = xs_dict_append(notify, "version", xs_stock(0));
+        notify = xs_dict_append(notify, "updated_at", s_tid);
+        data = xs_dict_set(data, "notifications", notify);
+        written = xs_dict_append(written, "notifications", notify);
+    }
+
+    if ((f = fopen(fn, "w")) != NULL) {
+        xs_json_dump(data, 4, f);
+        fclose(f);
+    }
+
+    return written;
+}
 
 void notify_add(snac *snac, const char *type, const char *utype,
                 const char *actor, const char *objid, const xs_dict *msg)
@@ -3767,3 +3852,108 @@ xs_str *make_url(const char *href, const char *proxy, int by_token)
 
     return url;
 }
+
+
+/** bad login throttle **/
+
+xs_str *_badlogin_fn(const char *addr)
+{
+    xs *md5 = xs_md5_hex(addr, strlen(addr));
+    xs *dir = xs_fmt("%s/badlogin", srv_basedir);
+
+    mkdirx(dir);
+
+    return xs_fmt("%s/%s", dir, md5);
+}
+
+
+int _badlogin_read(const char *fn, int *failures)
+/* reads a badlogin file */
+{
+    int ok = 0;
+    FILE *f;
+
+    pthread_mutex_lock(&data_mutex);
+
+    if ((f = fopen(fn, "r")) != NULL) {
+        xs *l = xs_readline(f);
+        fclose(f);
+
+        if (sscanf(l, "%d", failures) == 1)
+            ok = 1;
+    }
+
+    pthread_mutex_unlock(&data_mutex);
+
+    return ok;
+}
+
+
+int badlogin_check(const char *user, const char *addr)
+/* checks if this address is authorized to try a login */
+{
+    int valid = 1;
+
+    if (xs_type(addr) == XSTYPE_STRING) {
+        xs *fn = _badlogin_fn(addr);
+        double mt = mtime(fn);
+
+        if (mt > 0) {
+            int badlogin_expire = xs_number_get(xs_dict_get_def(srv_config,
+                                        "badlogin_expire", "300"));
+
+            mt += badlogin_expire;
+
+            /* if file is expired, delete and give pass */
+            if (mt < time(NULL)) {
+                srv_debug(1, xs_fmt("Login from %s for %s allowed again", addr, user));
+                unlink(fn);
+            }
+            else {
+                int failures;
+
+                if (_badlogin_read(fn, &failures)) {
+                    int badlogin_max = xs_number_get(xs_dict_get_def(srv_config,
+                                            "badlogin_retries", "5"));
+
+                    if (failures >= badlogin_max) {
+                        valid = 0;
+
+                        xs *d = xs_str_iso_date((time_t) mt);
+
+                        srv_debug(1,
+                            xs_fmt("Login from %s for %s forbidden until %s", addr, user, d));
+                    }
+                }
+            }
+        }
+    }
+
+    return valid;
+}
+
+
+void badlogin_inc(const char *user, const char *addr)
+/* increments a bad login from this address */
+{
+    if (xs_type(addr) == XSTYPE_STRING) {
+        int failures = 0;
+        xs *fn = _badlogin_fn(addr);
+        FILE *f;
+
+        _badlogin_read(fn, &failures);
+
+        pthread_mutex_lock(&data_mutex);
+
+        if ((f = fopen(fn, "w")) != NULL) {
+            failures++;
+
+            fprintf(f, "%d %s %s\n", failures, addr, user);
+            fclose(f);
+
+            srv_log(xs_fmt("Registered %d login failure(s) from %s for %s", failures, addr, user));
+        }
+
+        pthread_mutex_unlock(&data_mutex);
+    }
+}

+ 29 - 0
doc/snac.8

@@ -242,6 +242,12 @@ posts will not be direct ones, but proxied by
 This way, remote media servers will not see the user's IP, but the server one,
 improving privacy. Please take note that this will increase the server's incoming
 and outgoing traffic.
+.It Ic badlogin_retries
+If incorrect logins from a given IP address reach this count, subsequent attempts
+from it are rejected until the lock expires (default: 5 retries).
+.It Ic badlogin_expire
+The number of seconds a blocked IP address is ignored in login attempts
+(default: 300 seconds).
 .El
 .Pp
 You must restart the server to make effective these changes.
@@ -546,6 +552,22 @@ heavily on how all the servers involved behave. Just cross your fingers and hope
 Full instances can be blocked. This operation must be done from
 the command-line tool. See
 .Xr snac 1 .
+.Pp
+.Ss Bad login throttling
+Since version 2.67, a simple logic to avoid brute force attacks against user passwords
+has been implemented: if, from a given IP address, the number of failed logins reaches
+a given threshold, further tries from that IP address are never successful until a timer
+expires. The maximum number of retries can be configured in the 
+.Pa server.json
+file by setting the
+.Ic badlogin_retries
+variable, and the number of seconds the IP address unlock timer expires, in
+.Ic badlogin_expire .
+Please take note that, for this system to work, you must setup your web server proxy
+to pass the remote connection address in the
+.Ic X-Forwarded-For
+HTTP header (unless you use the FastCGI interface; if that's the case, you don't have
+to do anything).
 .Sh ENVIRONMENT
 .Bl -tag -width Ds
 .It Ev DEBUG
@@ -603,35 +625,42 @@ example.com server section:
 location /fedi {
     proxy_pass http://localhost:8001;
     proxy_set_header Host $http_host;
+    proxy_set_header X-Forwarded-For $remote_addr;
 }
 # webfinger
 location /.well-known/webfinger {
     proxy_pass http://localhost:8001;
     proxy_set_header Host $http_host;
+    proxy_set_header X-Forwarded-For $remote_addr;
 }
 # Mastodon API (entry points)
 location /api/v1/ {
     proxy_pass http://localhost:8001;
     proxy_set_header Host $http_host;
+    proxy_set_header X-Forwarded-For $remote_addr;
 }
 location /api/v2/ {
     proxy_pass http://localhost:8001;
     proxy_set_header Host $http_host;
+    proxy_set_header X-Forwarded-For $remote_addr;
 }
 # Mastodon API (OAuth support)
 location /oauth {
     proxy_pass http://localhost:8001;
     proxy_set_header Host $http_host;
+    proxy_set_header X-Forwarded-For $remote_addr;
 }
 # optional
 location /.well-known/nodeinfo {
     proxy_pass http://localhost:8001;
     proxy_set_header Host $http_host;
+    proxy_set_header X-Forwarded-For $remote_addr;
 }
 # optional (needed by some Mastodon API clients)
 location /.well-known/host-meta {
     proxy_pass http://localhost:8001;
     proxy_set_header Host $http_host;
+    proxy_set_header X-Forwarded-For $remote_addr;
 }
 .Ed
 .Pp

+ 77 - 0
examples/static-linking-with-musl.md

@@ -0,0 +1,77 @@
+# How to build a statically linked Snac with musl
+
+Prepare the environment
+```
+mkdir build
+cd build
+export BUILD_TARGET=$PWD
+export CC="musl-gcc"
+```
+
+Download and build latest zlib
+```
+wget http://zlib.net/current/zlib.tar.gz
+tar xvf zlib.tar.gz
+cd zlib-1.3.1/
+./configure --prefix=$BUILD_TARGET --static
+make
+make install
+cd ..
+```
+
+Download and build latest openssl
+```
+wget https://github.com/openssl/openssl/releases/download/openssl-3.4.0/openssl-3.4.0.tar.gz
+tar xvf openssl-3.4.0.tar.gz
+cd openssl-3.4.0
+CC="musl-gcc -fPIE -pie -static -idirafter /usr/include/ -idirafter /usr/include/x86_64-linux-gnu/" \
+   ./Configure no-shared no-async --prefix=$BUILD_TARGET --openssldir=$BUILD_TARGET/ssl linux-x86_64
+make depend
+make
+make install
+cd ..
+```
+
+Download and build latest curl
+```
+wget https://curl.se/download/curl-7.88.1.tar.gz
+tar xvf curl-7.88.1.tar.gz
+cd curl-7.88.1
+./configure --disable-shared --enable-static --disable-silent-rules \
+            --disable-debug --disable-warnings --disable-werror \
+            --disable-curldebug --disable-symbol-hiding --disable-ares \
+            --disable-rt --disable-ech --disable-dependency-tracking \
+            --disable-libtool-lock --enable-http --disable-ftp \
+            --disable-file --disable-ldap --disable-ldaps \
+            --disable-rtsp --disable-proxy --disable-dict \
+            --disable-telnet --disable-tftp --disable-pop3 \
+            --disable-imap --disable-smb --disable-smtp --disable-gopher \
+            --disable-mqtt --disable-manual --disable-libcurl-option --disable-ipv6 \
+            --disable-openssl-auto-load-config --disable-versioned-symbols 
+            --disable-verbose --disable-sspi --disable-crypto-auth \
+            --disable-ntlm --disable-ntlm-wb --disable-tls-srp \
+            --disable-unix-sockets --disable-cookies --disable-socketpair \
+            --disable-http-auth --disable-doh --disable-mime --disable-dateparse \
+            --disable-netrc --disable-progress-meter --disable-dnsshuffle \
+            --disable-get-easy-options --disable-alt-svc --disable-websockets \
+            --without-brotli --without-zstd --without-libpsl --without-libgsasl \
+            --without-librtmp --without-winidn --disable-threaded-resolver  \
+            --with-openssl=$BUILD_TARGET/ --with-zlib=$BUILD_TARGET/ \
+            --prefix=$BUILD_TARGET/
+make
+make install
+cd ..
+```
+
+Download and build latest snac2
+```
+git clone https://codeberg.org/grunfink/snac2.git # or cd to your existing repo
+cd snac2
+make CFLAGS="-g -Wall -Wextra -pedantic -static -DWITHOUT_SHM" \
+     LDFLAGS="-L$BUILD_TARGET/lib64 -lcurl -lssl -lcrypto -lz" \
+     PREFIX=$BUILD_TARGET 
+make install PREFIX=$BUILD_TARGET
+cd ..
+```
+
+Finally a statically linked snac is ready at $BUILD_TARGET/bin

+ 38 - 14
format.c

@@ -163,14 +163,26 @@ static xs_str *format_line(const char *line, xs_list **attach)
                     const char *mime     = xs_mime_by_ext(img_url);
 
                     if (attach != NULL && xs_startswith(mime, "image/")) {
-                        xs *d = xs_dict_new();
-
-                        d = xs_dict_append(d, "mediaType", mime);
-                        d = xs_dict_append(d, "url",       img_url);
-                        d = xs_dict_append(d, "name",      alt_text);
-                        d = xs_dict_append(d, "type",      "Image");
-
-                        *attach = xs_list_append(*attach, d);
+                        const xs_dict *ad;
+                        int add = 1;
+
+                        xs_list_foreach(*attach, ad) {
+                            if (strcmp(xs_dict_get_def(ad, "url", ""), img_url) == 0) {
+                                add = 0;
+                                break;
+                            }
+                        }
+
+                        if (add) {
+                            xs *d = xs_dict_new();
+
+                            d = xs_dict_append(d, "mediaType", mime);
+                            d = xs_dict_append(d, "url",       img_url);
+                            d = xs_dict_append(d, "name",      alt_text);
+                            d = xs_dict_append(d, "type",      "Image");
+
+                            *attach = xs_list_append(*attach, d);
+                        }
                     }
                     else {
                         xs *link = xs_fmt("<a href=\"%s\">%s</a>", img_url, alt_text);
@@ -191,14 +203,26 @@ static xs_str *format_line(const char *line, xs_list **attach)
 
                 if (attach != NULL && xs_startswith(mime, "image/")) {
                     /* if it's a link to an image, insert it as an attachment */
-                    xs *d = xs_dict_new();
+                    const xs_dict *ad;
+                    int add = 1;
+
+                    xs_list_foreach(*attach, ad) {
+                        if (strcmp(xs_dict_get_def(ad, "url", ""), v2) == 0) {
+                            add = 0;
+                            break;
+                        }
+                    }
 
-                    d = xs_dict_append(d, "mediaType", mime);
-                    d = xs_dict_append(d, "url",       v2);
-                    d = xs_dict_append(d, "name",      "");
-                    d = xs_dict_append(d, "type",      "Image");
+                    if (add) {
+                        xs *d = xs_dict_new();
+
+                        d = xs_dict_append(d, "mediaType", mime);
+                        d = xs_dict_append(d, "url",       v2);
+                        d = xs_dict_append(d, "name",      "");
+                        d = xs_dict_append(d, "type",      "Image");
 
-                    *attach = xs_list_append(*attach, d);
+                        *attach = xs_list_append(*attach, d);
+                    }
                 }
                 else {
                     xs *s1 = xs_fmt("<a href=\"%s\" target=\"_blank\">%s</a>", v2, u);

+ 86 - 4
html.c

@@ -29,9 +29,18 @@ int login(snac *snac, const xs_dict *headers)
         xs *l1 = xs_split_n(s2, ":", 1);
 
         if (xs_list_len(l1) == 2) {
-            logged_in = check_password(
-                xs_list_get(l1, 0), xs_list_get(l1, 1),
-                xs_dict_get(snac->config, "passwd"));
+            const char *user = xs_list_get(l1, 0);
+            const char *pwd  = xs_list_get(l1, 1);
+            const char *addr = xs_or(xs_dict_get(headers, "remote-addr"),
+                                     xs_dict_get(headers, "x-forwarded-for"));
+
+            if (badlogin_check(user, addr)) {
+                logged_in = check_password(user, pwd,
+                    xs_dict_get(snac->config, "passwd"));
+
+                if (!logged_in)
+                    badlogin_inc(user, addr);
+            }
         }
     }
 
@@ -633,6 +642,17 @@ xs_html *html_user_head(snac *user, const char *desc, const char *url)
     else
         s_desc = xs_dup(desc);
 
+    /* show metrics in og:description? */
+    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));
+
+        s_desc = xs_str_prepend_i(s_desc, s1);
+    }
+
     /* shorten desc to a reasonable size */
     for (n = 0; s_desc[n]; n++) {
         if (n > 512 && (s_desc[n] == ' ' || s_desc[n] == '\n'))
@@ -2041,6 +2061,23 @@ xs_html *html_entry(snac *user, xs_dict *msg, int read_only,
             if (content && xs_str_in(content, o_href) != -1)
                 continue;
 
+            /* do this attachment include an icon? */
+            const xs_dict *icon = xs_dict_get(a, "icon");
+            if (xs_type(icon) == XSTYPE_DICT) {
+                const char *icon_mtype = xs_dict_get(icon, "mediaType");
+                const char *icon_url   = xs_dict_get(icon, "url");
+
+                if (icon_mtype && icon_url && xs_startswith(icon_mtype, "image/")) {
+                    xs_html_add(content_attachments,
+                        xs_html_tag("a",
+                            xs_html_attr("href", icon_url),
+                            xs_html_attr("target", "_blank"),
+                            xs_html_sctag("img",
+                                xs_html_attr("loading", "lazy"),
+                                xs_html_attr("src", icon_url))));
+                }
+            }
+
             xs *href = make_url(o_href, proxy, 0);
 
             if (xs_startswith(type, "image/") || strcmp(type, "Image") == 0) {
@@ -2996,9 +3033,54 @@ int html_get_handler(const xs_dict *req, const char *q_path,
         }
         else {
             const char *q = xs_dict_get(q_vars, "q");
+            xs *url_acct = NULL;
+
+            /* searching for an URL? */
+            if (q && xs_match(q, "https://*|http://*")) {
+                /* may by an actor; try a webfinger */
+                xs *actor_obj = NULL;
+
+                if (valid_status(webfinger_request(q, &actor_obj, &url_acct))) {
+                    /* it's an actor; do the dirty trick of changing q to the account name */
+                    q = url_acct;
+                }
+                else {
+                    /* if it's not already here, try to bring it to the user's timeline */
+                    xs *md5 = xs_md5_hex(q, strlen(q));
+
+                    if (!timeline_here(&snac, md5)) {
+                        xs *object = NULL;
+                        int status;
+
+                        status = activitypub_request(&snac, q, &object);
+                        snac_debug(&snac, 1, xs_fmt("Request searched URL %s %d", q, status));
+
+                        if (valid_status(status)) {
+                            /* got it; also request the actor */
+                            const char *attr_to = get_atto(object);
+
+                            if (!xs_is_null(attr_to)) {
+                                status = actor_request(&snac, attr_to, &actor_obj);
+
+                                snac_debug(&snac, 1, xs_fmt("Request author %s of %s %d", attr_to, q, status));
+
+                                if (valid_status(status)) {
+                                    /* add the actor */
+                                    actor_add(attr_to, actor_obj);
+
+                                    /* add the post to the timeline */
+                                    timeline_add(&snac, q, object);
+                                }
+                            }
+                        }
+                    }
+                }
+
+                /* fall through */
+            }
 
             if (q && *q) {
-                if (xs_regex_match(q, "^@?[a-zA-Z0-9_]+@[a-zA-Z0-9-]+\\.")) {
+                if (xs_regex_match(q, "^@?[a-zA-Z0-9._]+@[a-zA-Z0-9-]+\\.")) {
                     /** search account **/
                     xs *actor = NULL;
                     xs *acct = NULL;

+ 24 - 12
httpd.c

@@ -279,6 +279,7 @@ void httpd_connection(FILE *f)
     xs *payload  = NULL;
     xs *etag     = NULL;
     xs *last_modified = NULL;
+    xs *link     = NULL;
     int p_size   = 0;
     const char *p;
     int fcgi_id;
@@ -326,7 +327,7 @@ void httpd_connection(FILE *f)
             status = oauth_get_handler(req, q_path, &body, &b_size, &ctype);
 
         if (status == 0)
-            status = mastoapi_get_handler(req, q_path, &body, &b_size, &ctype);
+            status = mastoapi_get_handler(req, q_path, &body, &b_size, &ctype, &link);
 #endif /* NO_MASTODON_API */
 
         if (status == 0)
@@ -426,6 +427,8 @@ void httpd_connection(FILE *f)
         headers = xs_dict_append(headers, "etag", etag);
     if (!xs_is_null(last_modified))
         headers = xs_dict_append(headers, "last-modified", last_modified);
+    if (!xs_is_null(link))
+        headers = xs_dict_append(headers, "Link", link);
 
     /* if there are any additional headers, add them */
     const xs_dict *more_headers = xs_dict_get(srv_config, "http_headers");
@@ -775,6 +778,26 @@ void httpd(void)
     xs *shm_name = NULL;
     sem_t anon_job_sem;
     xs *pidfile = xs_fmt("%s/server.pid", srv_basedir);
+    int pidfd;
+
+    {
+        /* do some pidfile locking acrobatics */
+        if ((pidfd = open(pidfile, O_RDWR | O_CREAT, 0660)) == -1) {
+            srv_log(xs_fmt("Cannot create pidfile %s -- cannot continue", pidfile));
+            return;
+        }
+
+        if (lockf(pidfd, F_TLOCK, 1) == -1) {
+            srv_log(xs_fmt("Cannot lock pidfile %s -- server already running?", pidfile));
+            close(pidfd);
+            return;
+        }
+
+        ftruncate(pidfd, 0);
+
+        xs *s = xs_fmt("%d\n", (int)getpid());
+        write(pidfd, s, strlen(s));
+    }
 
     address = xs_dict_get(srv_config, "address");
 
@@ -810,17 +833,6 @@ 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);

+ 192 - 67
mastoapi.c

@@ -293,47 +293,54 @@ int oauth_post_handler(const xs_dict *req, const char *q_path,
             snac snac;
 
             if (user_open(&snac, login)) {
-                /* check the login + password */
-                if (check_password(login, passwd, xs_dict_get(snac.config, "passwd"))) {
-                    /* success! redirect to the desired uri */
-                    xs *code = random_str();
+                const char *addr = xs_or(xs_dict_get(req, "remote-addr"),
+                                         xs_dict_get(req, "x-forwarded-for"));
 
-                    xs_free(*body);
+                if (badlogin_check(login, addr)) {
+                    /* check the login + password */
+                    if (check_password(login, passwd, xs_dict_get(snac.config, "passwd"))) {
+                        /* success! redirect to the desired uri */
+                        xs *code = random_str();
 
-                    if (strcmp(redir, "urn:ietf:wg:oauth:2.0:oob") == 0) {
-                        *body = xs_dup(code);
-                    }
-                    else {
-                        if (xs_str_in(redir, "?") != -1)
-                            *body = xs_fmt("%s&code=%s", redir, code);
-                        else
-                            *body = xs_fmt("%s?code=%s", redir, code);
+                        xs_free(*body);
 
-                        status = HTTP_STATUS_SEE_OTHER;
-                    }
+                        if (strcmp(redir, "urn:ietf:wg:oauth:2.0:oob") == 0) {
+                            *body = xs_dup(code);
+                        }
+                        else {
+                            if (xs_str_in(redir, "?") != -1)
+                                *body = xs_fmt("%s&code=%s", redir, code);
+                            else
+                                *body = xs_fmt("%s?code=%s", redir, code);
 
-                    /* if there is a state, add it */
-                    if (!xs_is_null(state) && *state) {
-                        *body = xs_str_cat(*body, "&state=");
-                        *body = xs_str_cat(*body, state);
-                    }
+                            status = HTTP_STATUS_SEE_OTHER;
+                        }
 
-                    srv_log(xs_fmt("oauth x-snac-login: '%s' success, redirect to %s",
+                        /* if there is a state, add it */
+                        if (!xs_is_null(state) && *state) {
+                            *body = xs_str_cat(*body, "&state=");
+                            *body = xs_str_cat(*body, state);
+                        }
+
+                        srv_log(xs_fmt("oauth x-snac-login: '%s' success, redirect to %s",
                                    login, *body));
 
-                    /* assign the login to the app */
-                    xs *app = app_get(cid);
+                        /* assign the login to the app */
+                        xs *app = app_get(cid);
 
-                    if (app != NULL) {
-                        app = xs_dict_set(app, "uid",  login);
-                        app = xs_dict_set(app, "code", code);
-                        app_add(cid, app);
+                        if (app != NULL) {
+                            app = xs_dict_set(app, "uid",  login);
+                            app = xs_dict_set(app, "code", code);
+                            app_add(cid, app);
+                        }
+                        else
+                            srv_log(xs_fmt("oauth x-snac-login: error getting app %s", cid));
+                    }
+                    else {
+                        srv_debug(1, xs_fmt("oauth x-snac-login: login '%s' incorrect", login));
+                        badlogin_inc(login, addr);
                     }
-                    else
-                        srv_log(xs_fmt("oauth x-snac-login: error getting app %s", cid));
                 }
-                else
-                    srv_debug(1, xs_fmt("oauth x-snac-login: login '%s' incorrect", login));
 
                 user_free(&snac);
             }
@@ -474,29 +481,36 @@ int oauth_post_handler(const xs_dict *req, const char *q_path,
             snac user;
 
             if (user_open(&user, login)) {
-                /* check the login + password */
-                if (check_password(login, passwd, xs_dict_get(user.config, "passwd"))) {
-                    /* success! create a new token */
-                    xs *tokid = random_str();
+                const char *addr = xs_or(xs_dict_get(req, "remote-addr"),
+                                         xs_dict_get(req, "x-forwarded-for"));
 
-                    srv_debug(1, xs_fmt("x-snac-new-token: "
+                if (badlogin_check(login, addr)) {
+                    /* check the login + password */
+                    if (check_password(login, passwd, xs_dict_get(user.config, "passwd"))) {
+                        /* success! create a new token */
+                        xs *tokid = random_str();
+
+                        srv_debug(1, xs_fmt("x-snac-new-token: "
                                     "successful login for %s, new token %s", login, tokid));
 
-                    xs *token = xs_dict_new();
-                    token = xs_dict_append(token, "token",         tokid);
-                    token = xs_dict_append(token, "client_id",     "snac-client");
-                    token = xs_dict_append(token, "client_secret", "");
-                    token = xs_dict_append(token, "uid",           login);
-                    token = xs_dict_append(token, "code",          "");
+                        xs *token = xs_dict_new();
+                        token = xs_dict_append(token, "token",         tokid);
+                        token = xs_dict_append(token, "client_id",     "snac-client");
+                        token = xs_dict_append(token, "client_secret", "");
+                        token = xs_dict_append(token, "uid",           login);
+                        token = xs_dict_append(token, "code",          "");
 
-                    token_add(tokid, token);
+                        token_add(tokid, token);
 
-                    *ctype = "text/plain";
-                    xs_free(*body);
-                    *body = xs_dup(tokid);
-                }
+                        *ctype = "text/plain";
+                        xs_free(*body);
+                        *body = xs_dup(tokid);
+                    }
+                    else
+                        badlogin_inc(login, addr);
 
-                user_free(&user);
+                    user_free(&user);
+                }
             }
         }
     }
@@ -898,7 +912,7 @@ xs_dict *mastoapi_status(snac *snac, const xs_dict *msg)
             const char *o_href = xs_dict_get(v, "href");
             const char *name = xs_dict_get(v, "name");
 
-            if (xs_match(type, "image/*|video/*|Image|Video")) { /* */
+            if (xs_match(type, "image/*|video/*|audio/*|Image|Video")) { /* */
                 xs *matteid = xs_fmt("%s_%d", id, xs_list_len(matt));
                 xs *href = make_url(o_href, proxy, 1);
 
@@ -910,7 +924,8 @@ xs_dict *mastoapi_status(snac *snac, const xs_dict *msg)
                 d = xs_dict_append(d, "remote_url",  href);
                 d = xs_dict_append(d, "description", name);
 
-                d = xs_dict_append(d, "type", (*type == 'v' || *type == 'V') ? "video" : "image");
+                d = xs_dict_append(d, "type", (*type == 'v' || *type == 'V') ? "video" :
+                                              (*type == 'a' || *type == 'A') ? "audio" : "image");
 
                 matt = xs_list_append(matt, d);
             }
@@ -990,7 +1005,7 @@ xs_dict *mastoapi_status(snac *snac, const xs_dict *msg)
                     const char *o_url = xs_dict_get(icon, "url");
 
                     if (!xs_is_null(o_url)) {
-                        xs *url = make_url(o_url, snac->actor, 1);
+                        xs *url = make_url(o_url, snac ? snac->actor : NULL, 1);
                         xs *nm = xs_strip_chars_i(xs_dup(name), ":");
 
                         d1 = xs_dict_append(d1, "shortcode", nm);
@@ -1193,10 +1208,13 @@ int process_auth_token(snac *snac, const xs_dict *req)
     return logged_in;
 }
 
+
 void credentials_get(char **body, char **ctype, int *status, snac snac)
 {
     xs *acct = xs_dict_new();
 
+    const xs_val *bot = xs_dict_get(snac.config, "bot");
+
     acct = xs_dict_append(acct, "id", snac.md5);
     acct = xs_dict_append(acct, "username", xs_dict_get(snac.config, "uid"));
     acct = xs_dict_append(acct, "acct", xs_dict_get(snac.config, "uid"));
@@ -1206,7 +1224,7 @@ void credentials_get(char **body, char **ctype, int *status, snac snac)
     acct = xs_dict_append(acct, "note", xs_dict_get(snac.config, "bio"));
     acct = xs_dict_append(acct, "url", snac.actor);
     acct = xs_dict_append(acct, "locked", xs_stock(XSTYPE_FALSE));
-    acct = xs_dict_append(acct, "bot", xs_dict_get(snac.config, "bot"));
+    acct = xs_dict_append(acct, "bot", xs_stock(xs_is_true(bot) ? XSTYPE_TRUE : XSTYPE_FALSE));
     acct = xs_dict_append(acct, "emojis", xs_stock(XSTYPE_LIST));
 
     xs *src = xs_json_loads("{\"privacy\":\"public\", \"language\":\"en\","
@@ -1220,7 +1238,7 @@ void credentials_get(char **body, char **ctype, int *status, snac snac)
     src = xs_dict_set(src, "sensitive",
         strcmp(cw, "open") == 0 ? xs_stock(XSTYPE_TRUE) : xs_stock(XSTYPE_FALSE));
 
-    src = xs_dict_set(src, "bot", xs_dict_get(snac.config, "bot"));
+    src = xs_dict_set(src, "bot", xs_stock(xs_is_true(bot) ? XSTYPE_TRUE : XSTYPE_FALSE));
 
     xs *avatar = NULL;
     const char *av = xs_dict_get(snac.config, "avatar");
@@ -1319,7 +1337,7 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
 
     const char *max_id   = xs_dict_get(args, "max_id");
     const char *since_id = xs_dict_get(args, "since_id");
-    const char *min_id   = xs_dict_get(args, "min_id");
+    const char *min_id   = xs_dict_get(args, "min_id"); /* unsupported old-to-new navigation */
     const char *limit_s  = xs_dict_get(args, "limit");
     int limit = 0;
     int cnt   = 0;
@@ -1330,7 +1348,7 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
     if (limit == 0)
         limit = 20;
 
-    if (index_desc_first(f, md5, 0)) {
+    if (min_id == NULL && index_desc_first(f, md5, 0)) {
         do {
             xs *msg = NULL;
 
@@ -1348,13 +1366,6 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
                     break;
             }
 
-            /* only returns entries newer than min_id */
-            /* what does really "Return results immediately newer than ID" mean? */
-            if (min_id) {
-                if (strcmp(md5, MID_TO_MD5(min_id)) == 0)
-                    break;
-            }
-
             /* get the entry */
             if (user) {
                 if (!valid_status(timeline_get_by_md5(user, md5, &msg)))
@@ -1438,8 +1449,35 @@ xs_list *mastoapi_timeline(snac *user, const xs_dict *args, const char *index_fn
 }
 
 
+xs_str *timeline_link_header(const char *endpoint, xs_list *timeline)
+/* returns a Link header with paging information */
+{
+    xs_str *s = NULL;
+
+    if (xs_list_len(timeline) == 0)
+        return NULL;
+
+    const xs_dict *first_st = xs_list_get(timeline, 0);
+    const xs_dict *last_st  = xs_list_get(timeline, -1);
+    const char *first_id    = xs_dict_get(first_st, "id");
+    const char *last_id     = xs_dict_get(last_st, "id");
+    const char *host        = xs_dict_get(srv_config, "host");
+    const char *protocol    = xs_dict_get_def(srv_config, "protocol", "https");
+
+    s = xs_fmt(
+        "<%s:/" "/%s%s?max_id=%s>; rel=\"next\", "
+        "<%s:/" "/%s%s?since_id=%s>; rel=\"prev\"",
+        protocol, host, endpoint, last_id,
+        protocol, host, endpoint, first_id);
+
+    srv_debug(1, xs_fmt("timeline_link_header %s", s));
+
+    return s;
+}
+
+
 int mastoapi_get_handler(const xs_dict *req, const char *q_path,
-                         char **body, int *b_size, char **ctype)
+                         char **body, int *b_size, char **ctype, xs_str **link)
 {
     (void)b_size;
 
@@ -1699,6 +1737,8 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
             xs *ifn = user_index_fn(&snac1, "private");
             xs *out = mastoapi_timeline(&snac1, args, ifn);
 
+            *link = timeline_link_header("/api/v1/timelines/home", out);
+
             *body  = xs_json_dumps(out, 4);
             *ctype = "application/json";
             status = HTTP_STATUS_OK;
@@ -1763,8 +1803,14 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
             xs *out    = xs_list_new();
             const xs_dict *v;
             const xs_list *excl = xs_dict_get(args, "exclude_types[]");
+            const char *min_id = xs_dict_get(args, "min_id");
             const char *max_id = xs_dict_get(args, "max_id");
 
+            if (dbglevel) {
+                xs *js = xs_json_dumps(args, 0);
+                srv_debug(1, xs_fmt("mastoapi_notifications args %s", js));
+            }
+
             xs_list_foreach(l, v) {
                 xs *noti = notify_get(&snac1, v);
 
@@ -1795,6 +1841,12 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
                     continue;
                 }
 
+                if (min_id) {
+                    if (strcmp(fid, min_id) <= 0) {
+                        continue;
+                    }
+                }
+
                 /* convert the type */
                 if (strcmp(type, "Like") == 0 || strcmp(type, "EmojiReact") == 0)
                     type = "favourite";
@@ -1842,6 +1894,8 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
                 out = xs_list_append(out, mn);
             }
 
+            srv_debug(1, xs_fmt("mastoapi_notifications count %d", xs_list_len(out)));
+
             *body  = xs_json_dumps(out, 4);
             *ctype = "application/json";
             status = HTTP_STATUS_OK;
@@ -2273,9 +2327,22 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
     }
     else
     if (strcmp(cmd, "/v1/markers") == 0) { /** **/
-        *body  = xs_dup("{}");
-        *ctype = "application/json";
-        status = HTTP_STATUS_OK;
+        if (logged_in) {
+            const xs_list *timeline = xs_dict_get(args, "timeline[]");
+            xs_str *json = NULL;
+            if (!xs_is_null(timeline)) 
+                json = xs_json_dumps(markers_get(&snac1, timeline), 4);
+
+            if (!xs_is_null(json))
+                *body = json;
+            else
+                *body = xs_dup("{}");
+
+            *ctype = "application/json";
+            status = HTTP_STATUS_OK;
+        }
+        else
+            status = HTTP_STATUS_UNAUTHORIZED;
     }
     else
     if (strcmp(cmd, "/v1/followed_tags") == 0) { /** **/
@@ -2310,6 +2377,37 @@ int mastoapi_get_handler(const xs_dict *req, const char *q_path,
             if (xs_is_null(offset) || strcmp(offset, "0") == 0) {
                 /* reply something only for offset 0; otherwise,
                    apps like Tusky keep asking again and again */
+                if (xs_startswith(q, "https://")) {
+                    xs *md5 = xs_md5_hex(q, strlen(q));
+
+                    if (!timeline_here(&snac1, md5)) {
+                        xs *object = NULL;
+                        int status;
+
+                        status = activitypub_request(&snac1, q, &object);
+                        snac_debug(&snac1, 1, xs_fmt("Request searched URL %s %d", q, status));
+
+                        if (valid_status(status)) {
+                            /* got it; also request the actor */
+                            const char *attr_to = get_atto(object);
+                            xs *actor_obj = NULL;
+
+                            if (!xs_is_null(attr_to)) {
+                                status = actor_request(&snac1, attr_to, &actor_obj);
+
+                                snac_debug(&snac1, 1, xs_fmt("Request author %s of %s %d", attr_to, q, status));
+
+                                if (valid_status(status)) {
+                                    /* add the actor */
+                                    actor_add(attr_to, actor_obj);
+
+                                    /* add the post to the timeline */
+                                    timeline_add(&snac1, q, object);
+                                }
+                            }
+                        }
+                    }
+                }
 
                 if (!xs_is_null(q)) {
                     if (xs_is_null(type) || strcmp(type, "accounts") == 0) {
@@ -2945,6 +3043,7 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
                 status = HTTP_STATUS_UNPROCESSABLE_CONTENT;
         }
     }
+    else
     if (xs_startswith(cmd, "/v1/lists/")) { /** list maintenance **/
         if (logged_in) {
             xs *l = xs_split(cmd, "/");
@@ -2972,9 +3071,35 @@ int mastoapi_post_handler(const xs_dict *req, const char *q_path,
                 }
             }
         }
+    }
+    else if (strcmp(cmd, "/v1/markers") == 0) { /** **/
+        xs_str *json = NULL;
+        if (logged_in) {
+            const xs_str *home_marker = xs_dict_get(args, "home[last_read_id]");
+            if (xs_is_null(home_marker)) {
+                const xs_dict *home = xs_dict_get(args, "home");
+                if (!xs_is_null(home))
+                    home_marker = xs_dict_get(home, "last_read_id");
+            }
+            
+            const xs_str *notify_marker = xs_dict_get(args, "notifications[last_read_id]");
+            if (xs_is_null(notify_marker)) {
+                const xs_dict *notify = xs_dict_get(args, "notifications");
+                if (!xs_is_null(notify))
+                    notify_marker = xs_dict_get(notify, "last_read_id");
+            }
+            json = xs_json_dumps(markers_set(&snac, home_marker, notify_marker), 4);
+        }
+        if (!xs_is_null(json))
+            *body = json;
         else
-            status = HTTP_STATUS_UNPROCESSABLE_CONTENT;
+            *body = xs_dup("{}");
+
+        *ctype = "application/json";
+        status = HTTP_STATUS_OK;
     }
+    else
+        status = HTTP_STATUS_UNPROCESSABLE_CONTENT;
 
     /* user cleanup */
     if (logged_in)

+ 8 - 2
snac.h

@@ -1,7 +1,7 @@
 /* snac - A simple, minimalistic ActivityPub instance */
 /* copyright (c) 2022 - 2024 grunfink et al. / MIT license */
 
-#define VERSION "2.66"
+#define VERSION "2.67"
 
 #define USER_AGENT "snac/" VERSION
 
@@ -238,6 +238,9 @@ int notify_new_num(snac *snac);
 xs_list *notify_list(snac *snac, int skip, int show);
 void notify_clear(snac *snac);
 
+xs_dict *markers_get(snac *snac, const xs_list *markers);
+xs_dict *markers_set(snac *snac, const char *home_marker, const char *notify_marker);
+
 void inbox_add(const char *inbox);
 void inbox_add_by_actor(const xs_dict *actor);
 xs_list *inbox_list(void);
@@ -386,7 +389,7 @@ int oauth_post_handler(const xs_dict *req, const char *q_path,
                        const char *payload, int p_size,
                        char **body, int *b_size, char **ctype);
 int mastoapi_get_handler(const xs_dict *req, const char *q_path,
-                         char **body, int *b_size, char **ctype);
+                         char **body, int *b_size, char **ctype, xs_str **link);
 int mastoapi_post_handler(const xs_dict *req, const char *q_path,
                           const char *payload, int p_size,
                           char **body, int *b_size, char **ctype);
@@ -427,3 +430,6 @@ typedef struct {
 t_announcement *announcement(double after);
 
 xs_str *make_url(const char *href, const char *proxy, int by_token);
+
+int badlogin_check(const char *user, const char *addr);
+void badlogin_inc(const char *user, const char *addr);

+ 5 - 1
webfinger.c

@@ -82,7 +82,11 @@ int webfinger_request_signed(snac *snac, const char *qs, xs_str **actor, xs_str
 
     if (obj == NULL && valid_status(status) && payload) {
         obj = xs_json_loads(payload);
-        object_add(cached_qs, obj);
+
+        if (obj)
+            object_add(cached_qs, obj);
+        else
+            status = HTTP_STATUS_BAD_REQUEST;
     }
 
     if (obj) {

+ 1 - 1
xs_fcgi.h

@@ -190,7 +190,7 @@ xs_dict *xs_fcgi_request(FILE *f, xs_str **payload, int *p_size, int *fcgi_id)
                         q_vars = xs_url_vars(xs_list_get(pnv, 1));
                     }
                     else
-                    if (xs_match(k, "CONTENT_TYPE|CONTENT_LENGTH|HTTP_*")) {
+                    if (xs_match(k, "CONTENT_TYPE|CONTENT_LENGTH|REMOTE_ADDR|HTTP_*")) {
                         if (xs_startswith(k, "HTTP_"))
                             k = xs_crop_i(k, 5, 0);