"GOT", but the "O" is a cute, smiling pufferfish. Index | Thread | Search

From:
Omar Polo <op@omarpolo.com>
Subject:
add got-notify-http
To:
gameoftrees@openbsd.org
Date:
Wed, 27 Mar 2024 16:53:17 +0100

Download raw body.

Thread
  • Omar Polo:

    add got-notify-http

This is the first draft for got-notify-http.  It's starting to work
enough that I think it can be improved in-tree.

got-notify-http will allow to post a JSON representation of the changes
to a remote HTTP server, similarly to how got-notify-smtp does it via
email.

The missing bits are the parsing of the tag and branch notifications,
which will result in a parse error at the moment, and the parsing of the
diffstat, which is currently ignored.  The generated JSON is also wrong
when it comes to non-ASCII characters, see the XXX below.

The generated JSON is an array of notification objects, whose exact
layout is only indicative at the moment.  I'd like to improve it, but
all the information are there already.

Regarding the implementation: I've reused a bufferized I/O thingy I
wrote for some other personal projects.  It's async-friendly and has
optional TLS support (via libtls), and could also come in handy in an
eventual got-fetch-http and to add smtps/starttls support to
got-notify-smtp, so I'm inclided to include it as well.  I can replace
with something custom eventually if preferred.

A limitation is that we don't follow redirects.  More than a technical
reason is just to drop the "inet dns" promise and run the TLS and HTTP
machinery under "stdio" alone.  It would also allow to sandbox in
-portable.  A server replying with a redirect that force the client to
re-try the post at a new URL is not that common, so maybe it's an
acceptable limitation.

ok to import this and continue hacking in tree?


commit d35c51da3960ad29482ffe21c31d7f4f70a3e555 (http-notif)
from: Omar Polo <op@omarpolo.com>
date: Wed Mar 27 15:39:13 2024 UTC
 
 add got-notify-http
 
diff f9e6537008c7344a6107b88bc19c5f293cacd793 d35c51da3960ad29482ffe21c31d7f4f70a3e555
commit - f9e6537008c7344a6107b88bc19c5f293cacd793
commit + d35c51da3960ad29482ffe21c31d7f4f70a3e555
blob - 45a21b3bd385d22ec6ae514d311b7bd4a6753183
blob + 602eb98ac68d38179e233dd928e6198ad72b3e34
--- gotd/gotd.conf.5
+++ gotd/gotd.conf.5
@@ -254,15 +254,15 @@ The default content of email notifications looks simil
 .Cm got log -d
 command.
 .Pp
-.\" Notifications via HTTP require a HTTP or HTTPS server which is accepting
-.\" POST requests with or without HTTP Basic authentication.
-.\" Depending on the use case a custom server-side CGI script may be required
-.\" for the processing of notifications.
-.\" HTTP notifications can achieve functionality
-.\" similar to Git's server-side post-receive hook script with
-.\" .Xr gotd 8
-.\" by triggering arbitrary post-commit actions via the HTTP server.
-.\" .Pp
+Notifications via HTTP require a HTTP or HTTPS server which is accepting
+POST requests with or without HTTP Basic authentication.
+Depending on the use case a custom server-side CGI script may be required
+for the processing of notifications.
+HTTP notifications can achieve functionality
+similar to Git's server-side post-receive hook script with
+.Xr gotd 8
+by triggering arbitrary post-commit actions via the HTTP server.
+.Pp
 The
 .Ic notify
 directive expects parameters which must be enclosed in curly braces.
@@ -329,41 +329,40 @@ and
 .Ic port
 directives can be used to specify a different SMTP server address and port.
 .Pp
-.\" .It Ic url Ar URL Ic user Ar user Ic password Ar password Oc
-.\" Send notifications via HTTP.
-.\" This directive may be specified multiple times to build a list of
-.\" HTTP servers to send notifications to.
-.\" .Pp
-.\" The notification will be sent as a POST request to the given
-.\" .Ar URL ,
-.\" which must be a valid HTTP URL and begin with either
-.\" .Dq http://
-.\" or
-.\" .Dq https:// .
-.\" If HTTPS is used, sending of notifications will only succeed if
-.\" no TLS errors occur.
-.\" .Pp
-.\" The optional
-.\" .Ic user
-.\" and
-.\" .Ic password
-.\" directives enable HTTP Basic authentication.
-.\" If used, both a
-.\" .Ar user
-.\" and a
-.\" .Ar password
-.\" must be specified.
-.\" The
-.\" .Ar password
-.\" must not be an empty string.
-.\" .Pp
-.\" The request body contains a JSON document with the following objects:
-.\" .Bl -tag -width { "notifications" : array }
-.\" .It { "notifications" : array }
-.\" The top-level object contains an array of all notifications in this request.
-.\" .It TODO ...
-.\" .El
+.It Ic url Ar URL Oo Ic user Ar user Ic password Ar password Oc
+Send notifications via HTTP.
+This directive may be specified multiple times to build a list of
+HTTP servers to send notifications to.
+.Pp
+The notification will be sent as a POST request to the given
+.Ar URL ,
+which must be a valid HTTP URL and begin with either
+.Dq http://
+or
+.Dq https:// .
+If HTTPS is used, sending of notifications will only succeed if
+no TLS errors occur.
+.Pp
+The optional
+.Ic user
+and
+.Ic password
+directives enable HTTP Basic authentication.
+If used, both a
+.Ar user
+and a
+.Ar password
+must be specified.
+The
+.Ar password
+must not be an empty string.
+.Pp
+The request body contains a JSON document with the following objects:
+.Bl -tag -width { "notifications" : array }
+.It { "notifications" : array }
+The top-level object contains an array of all notifications in this request.
 .El
+.El
 .Sh FILES
 .Bl -tag -width Ds -compact
 .It Pa /etc/gotd.conf
blob - 0db514ba51566c92c7c7cf3d1df4c1b69e81c7fe
blob + c4375525ccc474f8fe090e2653580512c0faf220
--- gotd/gotd.h
+++ gotd/gotd.h
@@ -106,7 +106,10 @@ struct gotd_notification_target {
 			char *port;
 		} email;
 		struct {
-			char *url;
+			int   tls;
+			char *hostname;
+			char *port;
+			char *path;
 			char *user;
 			char *password;
 		} http;
blob - 59c09c35e0b26fb30a138fc1dd52d8f994826c32
blob + c813ab49420ca5ad0a04538cd3722ea5e71af243
--- gotd/libexec/Makefile
+++ gotd/libexec/Makefile
@@ -1,3 +1,3 @@
-SUBDIR = got-notify-email
+SUBDIR = got-notify-email got-notify-http
 
 .include <bsd.subdir.mk>
blob - /dev/null
blob + f7c019a6d9614f8bbaac0c5ec29a63e69b94b5ea (mode 644)
--- /dev/null
+++ gotd/libexec/got-notify-http/Makefile
@@ -0,0 +1,14 @@
+.PATH:${.CURDIR}/../..
+.PATH:${.CURDIR}/../../../lib
+
+.include "../../../got-version.mk"
+
+PROG=	got-notify-http
+SRCS=	got-notify-http.c bufio.c opentemp.c pollfd.c error.c hash.c
+
+CPPFLAGS= -I${.CURDIR}/../../../include -I${.CURDIR}/../../../lib
+
+DPADD=	${LIBTLS}
+LDADD=	-ltls
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + 779b64b8f926d8b54243bbffe99d9cb186659d70 (mode 644)
--- /dev/null
+++ gotd/libexec/got-notify-http/got-notify-http.c
@@ -0,0 +1,624 @@
+/*
+ * Copyright (c) 2024 Omar Polo <op@openbsd.org>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/time.h>
+#include <sys/types.h>
+#include <sys/socket.h>
+
+#include <err.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <limits.h>
+#include <netdb.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "got_opentemp.h"
+#include "got_version.h"
+
+#include "bufio.h"
+
+#define USERAGENT	 "got-notify-http/" GOT_VERSION_STR
+
+static int		 http_timeout = 300;	/* 5 minutes in seconds */
+
+__dead static void
+usage(void)
+{
+	fprintf(stderr, "usage: %s [-c] -h host -p port path\n",
+	    getprogname());
+	exit(1);
+}
+
+static int
+dial(const char *host, const char *port)
+{
+	struct addrinfo	 hints, *res, *res0;
+	const char	*cause = NULL;
+	int		 s, error, save_errno;
+
+	memset(&hints, 0, sizeof(hints));
+	hints.ai_family = AF_UNSPEC;
+	hints.ai_socktype = SOCK_STREAM;
+	error = getaddrinfo(host, port, &hints, &res0);
+	if (error)
+		errx(1, "failed to resolve %s:%s: %s", host, port,
+		    gai_strerror(error));
+
+	s = -1;
+	for (res = res0; res; res = res->ai_next) {
+		s = socket(res->ai_family, res->ai_socktype,
+		    res->ai_protocol);
+		if (s == -1) {
+			cause = "socket";
+			continue;
+		}
+
+		if (connect(s, res->ai_addr, res->ai_addrlen) == -1) {
+			cause = "connect";
+			save_errno = errno;
+			close(s);
+			errno = save_errno;
+			s = -1;
+			continue;
+		}
+
+		break;
+	}
+
+	freeaddrinfo(res0);
+	if (s == -1)
+		err(1, "%s", cause);
+	return s;
+}
+
+static void
+escape(FILE *fp, const uint8_t *s)
+{
+	for (; *s; ++s) {
+		/*
+		 * XXX: this is broken for UNICODE: we should leave
+		 * the multibyte characters as-is.
+		 */
+
+		if (*s >= ' ' && *s <= '~') {
+			fputc(*s, fp);
+			continue;
+		}
+
+		switch (*s) {
+		case '"':
+		case '\\':
+			fprintf(fp, "\\%c", *s);
+			break;
+		case '\b':
+			fprintf(fp, "\\b");
+			break;
+		case '\f':
+			fprintf(fp, "\\f");
+			break;
+		case '\n':
+			fprintf(fp, "\\n");
+			break;
+		case '\r':
+			fprintf(fp, "\\r");
+			break;
+		case '\t':
+			fprintf(fp, "\\t");
+			break;
+		default:
+			fprintf(fp, "\\u%04X", *s);
+			break;
+		}
+        }
+}
+
+static void
+json_field(FILE *fp, const char *key, const char *val, int comma)
+{
+	fprintf(fp, "\"%s\":\"", key);
+	escape(fp, val);
+	fprintf(fp, "\"%s", comma ? "," : "");
+}
+
+static int
+jsonify_short(FILE *fp)
+{
+	char	*t, *date, *id, *author, *message;
+	char	*line = NULL;
+	size_t	 linesize = 0;
+	ssize_t	 linelen;
+	int	 needcomma = 0;
+
+	fprintf(fp, "{\"notifications\":[");
+	while ((linelen = getline(&line, &linesize, stdin)) != -1) {
+		if (line[linelen - 1] == '\n')
+			line[--linelen] = '\0';
+
+		if (needcomma)
+			fputc(',', fp);
+		needcomma = 1;
+
+		t = line;
+		date = t;
+		if ((t = strchr(t, ' ')) == NULL)
+       			errx(1, "malformed line");
+		*t++ = '\0';
+
+		id = t;
+		if ((t = strchr(t, ' ')) == NULL)
+       			errx(1, "malformed line");
+		*t++ = '\0';
+
+		author = t;
+		if ((t = strchr(t, ' ')) == NULL)
+       			errx(1, "malformed line");
+		*t++ = '\0';
+
+		message = t;
+
+		fprintf(fp, "{\"short\":true,");
+		json_field(fp, "id", id, 1);
+		json_field(fp, "author", author, 1);
+		json_field(fp, "date", date, 1);
+		json_field(fp, "message", message, 0);
+		fprintf(fp, "}");
+	}
+
+	if (ferror(stdin))
+		err(1, "getline");
+	free(line);
+
+	fprintf(fp, "]}");
+
+	return 0;
+}
+
+static int
+jsonify(FILE *fp)
+{
+	const char	*errstr;
+	char		*l;
+	char		*line = NULL;
+	size_t		 linesize = 0;
+	ssize_t		 linelen;
+	int		 parent = 0;
+	int		 msglen = 0, msgwrote = 0;
+	int		 needcomma = 0;
+	enum {
+		P_COMMIT,
+		P_FROM,
+		P_VIA,
+		P_DATE,
+		P_PARENT,
+		P_MSGLEN,
+		P_MSG,
+		P_DST,
+		P_SUM,
+	} phase = P_COMMIT;
+
+	fprintf(fp, "{\"notifications\":[");
+	while ((linelen = getline(&line, &linesize, stdin)) != -1) {
+		if (line[linelen - 1] == '\n')
+			line[--linelen] = '\0';
+
+		l = line;
+		switch (phase) {
+		case P_COMMIT:
+			if (*line == '\0')
+				continue;
+
+			if (strncmp(l, "commit ", 7) != 0)
+				errx(1, "unexpected commit line: %s", line);
+			l += 7;
+
+			if (needcomma)
+				fputc(',', fp);
+			needcomma = 1;
+
+			fprintf(fp, "{\"short\":false,");
+			json_field(fp, "id", l, 1);
+
+			phase = P_FROM;
+			break;
+
+		case P_FROM:
+			if (strncmp(l, "from: ", 6) != 0)
+				errx(1, "unexpected from line");
+			l += 6;
+			json_field(fp, "author", l, 1);
+			phase = P_VIA;
+			break;
+
+		case P_VIA:
+			/* optional */
+			if (!strncmp(l, "via: ", 5)) {
+				l += 5;
+				json_field(fp, "via", l, 1);
+				phase = P_DATE;
+				break;
+			}
+			phase = P_DATE;
+			/* fallthrough */
+
+		case P_DATE:
+			/* optional */
+			if (!strncmp(l, "date: ", 6)) {
+				l += 6;
+				json_field(fp, "date", l, 1);
+				phase = P_PARENT;
+				break;
+			}
+			phase = P_PARENT;
+			/* fallthough */
+
+		case P_PARENT:
+			/* optional - more than one */
+			if (!strncmp(l, "parent ", 7)) {
+				l += 7;
+				l += strcspn(l, ":");
+				l += strspn(l, " ");
+
+				if (parent == 0) {
+					parent = 1;
+					fprintf(fp, "\"parents\":[");
+				}
+
+				fputc('"', fp);
+				escape(fp, l);
+				fputc('"', fp);
+
+				break;
+			}
+			if (parent != 0) {
+				fprintf(fp, "],");
+				parent = 0;
+			}
+			phase = P_MSGLEN;
+			/* fallthrough */
+
+		case P_MSGLEN:
+			if (strncmp(l, "messagelen: ", 12) != 0)
+				errx(1, "unexpected messagelen line");
+			l += 12;
+			msglen = strtonum(l, 1, INT_MAX, &errstr);
+			if (errstr)
+				errx(1, "message len is %s: %s", errstr, l);
+
+			fprintf(fp, "\"message\":\"");
+			phase = P_MSG;
+			break;
+
+		case P_MSG:
+			/*
+			 * The commit message is indented with one extra
+			 * space which is not accounted for in messagelen,
+			 * but we also strip the trailing \n so that
+			 * accounts for it.
+			 *
+			 * Since we read line-by-line and there is always
+			 * a \n added at the end of the message,
+			 * tolerate one byte less than advertised.
+			 */
+			if (*l == ' ') {
+				escape(fp, l + 1); /* skip leading space */
+
+				/* avoid pre-pending \n to the commit msg */
+				msgwrote += linelen - 1;
+				if (msgwrote != 0)
+					escape(fp, "\n");
+			}
+			msglen -= linelen;
+			if (msglen <= 1) {
+				fprintf(fp, "\",");
+				msgwrote = 0;
+				phase = P_DST;
+			}
+			break;
+
+		case P_DST:
+			/* XXX: ignore the diffstat for now */
+			if (*line == '\0') {
+				fprintf(fp, "\"diffstat\":{},");
+				phase = P_SUM;
+				break;
+			}
+			break;
+
+		case P_SUM:
+			/* XXX: ignore the sum of changes for now */
+			fprintf(fp, "\"changes\":{}}");
+			/* restart the state machine */
+			phase = P_COMMIT;
+			break;
+
+		default:
+			errx(1, "unimplemented");
+		}
+	}
+	if (ferror(stdin))
+		err(1, "getline");
+	if (phase != P_COMMIT)
+		errx(1, "unexpected EOF");
+	free(line);
+
+	fprintf(fp, "]}");
+
+	return 0;
+}
+
+static char *
+basic_auth(const char *username, const char *password)
+{
+	char	*tmp;
+	int	 len;
+
+	len = asprintf(&tmp, "%s:%s", username, password);
+	if (len == -1)
+		err(1, "asprintf");
+
+	/* XXX base64-ify */
+	return tmp;
+}
+
+static inline int
+bufio2poll(struct bufio *bio)
+{
+	int f, ret = 0;
+
+	f = bufio_ev(bio);
+	if (f & BUFIO_WANT_READ)
+		ret |= POLLIN;
+	if (f & BUFIO_WANT_WRITE)
+		ret |= POLLOUT;
+	return ret;
+}
+
+int
+main(int argc, char **argv)
+{
+	FILE		*tmpfp;
+	struct bufio	 bio;
+	struct pollfd	 pfd;
+	struct timespec	 timeout;
+	const char	*username;
+	const char	*password;
+	const char	*timeoutstr;
+	const char	*errstr;
+	const char	*host = NULL, *port = NULL, *path = NULL;
+	char		*auth, *line, *spc;
+	size_t		 len;
+	ssize_t		 r;
+	off_t		 paylen;
+	int		 tls = 0;
+	int		 response_code = 0, done = 0;
+	int		 ch, flags, ret, nonstd = 0;
+
+#ifndef PROFILE
+	if (pledge("stdio rpath tmppath dns inet", NULL) == -1)
+		err(1, "pledge");
+#endif
+
+	while ((ch = getopt(argc, argv, "ch:p:")) != -1) {
+		switch (ch) {
+		case 'c':
+			tls = 1;
+			break;
+		case 'h':
+			host = optarg;
+			break;
+		case 'p':
+			port = optarg;
+			break;
+		default:
+			usage();
+		}
+	}
+	argc -= optind;
+	argv += optind;
+
+	if (host == NULL || argc != 1)
+		usage();
+	if (tls && port == NULL)
+		port = "443";
+	path = argv[0];
+
+	username = getenv("GOT_NOTIFY_HTTP_USER");
+	password = getenv("GOT_NOTIFY_HTTP_PASS");
+	if ((username != NULL && password == NULL) ||
+	    (username == NULL && password != NULL))
+		errx(1, "username or password are not specified");
+	if (username && *password == '\0')
+		errx(1, "password can't be empty");
+
+	/* used by the regression test suite */
+	timeoutstr = getenv("GOT_NOTIFY_TIMEOUT");
+	if (timeoutstr) {
+		http_timeout = strtonum(timeoutstr, 0, 600, &errstr);
+		if (errstr != NULL)
+			errx(1, "timeout in seconds is %s: %s",
+			    errstr, timeoutstr);
+	}
+
+	memset(&timeout, 0, sizeof(timeout));
+	timeout.tv_sec = http_timeout;
+
+	tmpfp = got_opentemp();
+	if (tmpfp == NULL)
+		err(1, "opentemp");
+
+	/* detect the format of the input */
+	ch = fgetc(stdin);
+	if (ch == EOF)
+		errx(1, "unexpected EOF");
+	ungetc(ch, stdin);
+	if (ch == 'c') {
+		/* starts with "commit" so it's the long format */
+		jsonify(tmpfp);
+	} else {
+		/* starts with the date so it's the short format */
+		jsonify_short(tmpfp);
+	}
+
+	paylen = ftello(tmpfp);
+	if (paylen == -1)
+		err(1, "ftello");
+	if (fseeko(tmpfp, 0, SEEK_SET) == -1)
+		err(1, "fseeko");
+
+#ifndef PROFILE
+	/* drop tmppath */
+	if (pledge("stdio rpath dns inet", NULL) == -1)
+		err(1, "pledge");
+#endif
+
+	memset(&pfd, 0, sizeof(pfd));
+	pfd.fd = dial(host, port);
+
+	if ((flags = fcntl(pfd.fd, F_GETFL)) == -1)
+		err(1, "fcntl(F_GETFL)");
+	if (fcntl(pfd.fd, F_SETFL, flags | O_NONBLOCK) == -1)
+		err(1, "fcntl(F_SETFL)");
+
+	if (bufio_init(&bio) == -1)
+		err(1, "bufio_init");
+	bufio_set_fd(&bio, pfd.fd);
+	if (tls && bufio_starttls(&bio, host, 0, NULL, 0, NULL, 0) == -1)
+		err(1, "bufio_starttls");
+
+#ifndef PROFILE
+	/* drop rpath dns inet */
+	if (pledge("stdio", NULL) == -1)
+		err(1, "pledge");
+#endif
+
+	if ((!tls && strcmp(port, "80") != 0) ||
+	    (tls && strcmp(port, "443")) != 0)
+		nonstd = 1;
+
+	ret = bufio_compose_fmt(&bio,
+	    "POST %s HTTP/1.1\r\n"
+	    "Host: %s%s%s\r\n"
+	    "Content-Type: application/json\r\n"
+	    "Content-Length: %lld\r\n"
+	    "User-Agent: %s\r\n"
+	    "Connection: close\r\n",
+	    path, host,
+	    nonstd ? ":" : "", nonstd ? port : "",
+	    (long long)paylen, USERAGENT);
+	if (ret == -1)
+		err(1, "bufio_compose_fmt");
+
+	if (username) {
+		auth = basic_auth(username, password);
+		ret = bufio_compose_fmt(&bio, "Authorization: basic %s\r\n",
+		    auth);
+		if (ret == -1)
+			err(1, "bufio_compose_fmt");
+		free(auth);
+	}
+
+	if (bufio_compose(&bio, "\r\n", 2) == -1)
+		err(1, "bufio_compose");
+
+	while (!done) {
+		struct timespec	 elapsed, start, stop;
+		char		 buf[BUFSIZ];
+
+		pfd.events = bufio2poll(&bio);
+		clock_gettime(CLOCK_MONOTONIC, &start);
+		ret = ppoll(&pfd, 1, &timeout, NULL);
+		if (ret == -1)
+			err(1, "poll");
+		clock_gettime(CLOCK_MONOTONIC, &stop);
+		timespecsub(&stop, &start, &elapsed);
+		timespecsub(&timeout, &elapsed, &timeout);
+		if (ret == 0 || timeout.tv_sec <= 0)
+			errx(1, "timeout");
+
+		if (bio.wbuf.len > 0 && (pfd.revents & POLLOUT)) {
+			if (bufio_write(&bio) == -1 && errno != EAGAIN)
+				errx(1, "bufio_write: %s", bufio_io_err(&bio));
+		}
+		if (pfd.revents & POLLIN) {
+			r = bufio_read(&bio);
+			if (r == -1 && errno != EAGAIN)
+				errx(1, "bufio_read: %s", bufio_io_err(&bio));
+			if (r == 0)
+				errx(1, "unexpected EOF");
+
+			for (;;) {
+				line = buf_getdelim(&bio.rbuf, "\r\n", &len);
+				if (line == NULL)
+					break;
+				if (response_code && *line == '\0') {
+					/*
+					 * end of headers, don't bother
+					 * reading the body, if there is.
+					 */
+					done = 1;
+					break;
+				}
+				if (response_code) {
+					buf_drain(&bio.rbuf, len);
+					continue;
+				}
+				spc = strchr(line, ' ');
+				if (spc == NULL)
+					errx(1, "bad reply");
+				*spc++ = '\0';
+				if (strcasecmp(line, "HTTP/1.1") != 0)
+					errx(1, "unexpected protocol: %s",
+					    line);
+				line = spc;
+
+				spc = strchr(line, ' ');
+				if (spc == NULL)
+					errx(1, "bad reply");
+				*spc++ = '\0';
+
+				response_code = strtonum(line, 100, 599,
+				    &errstr);
+				if (errstr != NULL)
+					errx(1, "response code is %s: %s",
+					    errstr, line);
+
+				buf_drain(&bio.rbuf, len);
+			}
+			if (done)
+				break;
+		}
+
+		if (!feof(tmpfp) && bio.wbuf.len < sizeof(buf)) {
+			len = fread(buf, 1, sizeof(buf), tmpfp);
+			if (len == 0) {
+				if (ferror(tmpfp))
+					err(1, "fread");
+				continue;
+			}
+
+			if (bufio_compose(&bio, buf, len) == -1)
+				err(1, "buf_compose");
+		}
+	}
+
+	if (response_code >= 200 && response_code < 400)
+		return 0;
+	errx(1, "request failed with code %d", response_code);
+}
blob - 91e6586a11678bd1994490278888e2c6b0dc6314
blob + 130421d61e42a690b8ee37a09bbf38b3bc2da092
--- gotd/notify.c
+++ gotd/notify.c
@@ -253,11 +253,24 @@ notify_email(struct gotd_notification_target *target, 
 }
 
 static void
-notify_http(struct gotd_notification_target *target, const char *subject_line,
-    int fd)
+notify_http(struct gotd_notification_target *target, int fd)
 {
-	const char *argv[10] = { 0 }; /* TODO */
+	const char *argv[8];
+	int argc = 0;
 
+	argv[argc++] = GOTD_PATH_PROG_NOTIFY_HTTP;
+	if (target->conf.http.tls)
+		argv[argc++] = "-c";
+
+	argv[argc++] = "-h";
+	argv[argc++] = target->conf.http.hostname;
+	argv[argc++] = "-p";
+	argv[argc++] = target->conf.http.port;
+
+	argv[argc++] = target->conf.http.path;
+
+	argv[argc] = NULL;
+
 	run_notification_helper(GOTD_PATH_PROG_NOTIFY_HTTP, argv, fd);
 }
 
@@ -296,7 +309,7 @@ send_notification(struct imsg *imsg, struct gotd_imsge
 			notify_email(target, inotify.subject_line, fd);
 			break;
 		case GOTD_NOTIFICATION_VIA_HTTP:
-			notify_http(target, inotify.subject_line, fd);
+			notify_http(target, fd);
 			break;
 		}
 	}
blob - ad685fad9127a30b70881cfc8f2b608b5da20495
blob + ecec20991036938d1cd3fe4264832632cf47e167
--- gotd/parse.y
+++ gotd/parse.y
@@ -1538,16 +1538,18 @@ conf_notify_http(struct gotd_repo *repo, char *url, ch
 {
 	const struct got_error *error;
 	struct gotd_notification_target *target;
-	char *proto, *host, *port, *request_path;
-	int ret = 0;
+	char *proto, *hostname, *port, *path;
+	int tls = 0, ret = 0;
 
-	error = gotd_parse_url(&proto, &host, &port, &request_path, url);
+	error = gotd_parse_url(&proto, &hostname, &port, &path, url);
 	if (error) {
 		yyerror("invalid HTTP notification URL '%s' in "
 		    "repository '%s': %s", url, repo->name, error->msg);
 		return -1;
 	}
 
+	tls = !strcmp(proto, "https");
+
 	if (strcmp(proto, "http") != 0 && strcmp(proto, "https") != 0) {
 		yyerror("invalid protocol '%s' in notification URL '%s' in "
 		    "repository '%s", proto, url, repo->name);
@@ -1555,6 +1557,13 @@ conf_notify_http(struct gotd_repo *repo, char *url, ch
 		goto done;
 	}
 
+	if ((user != NULL && password == NULL) ||
+	    (user == NULL && password != NULL)) {
+		yyerror("missing username or password");
+		ret = -1;
+		goto done;
+	}
+
 	if (strcmp(proto, "http") == 0 && (user != NULL || password != NULL)) {
 		log_warnx("%s: WARNING: Using basic authentication over "
 		    "plaintext http:// will leak credentials; https:// is "
@@ -1564,7 +1573,10 @@ conf_notify_http(struct gotd_repo *repo, char *url, ch
 	STAILQ_FOREACH(target, &repo->notification_targets, entry) {
 		if (target->type != GOTD_NOTIFICATION_VIA_HTTP)
 			continue;
-		if (strcmp(target->conf.http.url, url) == 0) {
+		if (target->conf.http.tls == tls &&
+		    !strcmp(target->conf.http.hostname, hostname) &&
+		    !strcmp(target->conf.http.port, port) &&
+		    !strcmp(target->conf.http.path, path)) {
 			yyerror("duplicate notification for URL '%s' in "
 			    "repository '%s'", url, repo->name);
 			ret = -1;
@@ -1576,26 +1588,27 @@ conf_notify_http(struct gotd_repo *repo, char *url, ch
 	if (target == NULL)
 		fatal("calloc");
 	target->type = GOTD_NOTIFICATION_VIA_HTTP;
-	target->conf.http.url = strdup(url);
-	if (target->conf.http.url == NULL)
-		fatal("calloc");
+	target->conf.http.tls = tls;
+	target->conf.http.hostname = hostname;
+	target->conf.http.port = port;
+	target->conf.http.path = path;
+	hostname = port = path = NULL;
+
 	if (user) {
 		target->conf.http.user = strdup(user);
 		if (target->conf.http.user == NULL)
-			fatal("calloc");
-	}	
-	if (password) {
+			fatal("strdup");
 		target->conf.http.password = strdup(password);
 		if (target->conf.http.password == NULL)
-			fatal("calloc");
+			fatal("strdup");
 	}	
 
 	STAILQ_INSERT_TAIL(&repo->notification_targets, target, entry);
 done:
 	free(proto);
-	free(host);
+	free(hostname);
 	free(port);
-	free(request_path);
+	free(path);
 	return ret;
 }
 
blob - /dev/null
blob + 827a113ec3eaa7465036408230a7a7c14d55577b (mode 644)
--- /dev/null
+++ lib/bufio.c
@@ -0,0 +1,421 @@
+/*
+ * This is free and unencumbered software released into the public domain.
+ *
+ * Anyone is free to copy, modify, publish, use, compile, sell, or
+ * distribute this software, either in source code form or as a compiled
+ * binary, for any purpose, commercial or non-commercial, and by any
+ * means.
+ *
+ * In jurisdictions that recognize copyright laws, the author or authors
+ * of this software dedicate any and all copyright interest in the
+ * software to the public domain. We make this dedication for the benefit
+ * of the public at large and to the detriment of our heirs and
+ * successors. We intend this dedication to be an overt act of
+ * relinquishment in perpetuity of all present and future rights to this
+ * software under copyright law.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+ * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+#include <assert.h>
+#include <errno.h>
+#include <stdarg.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <tls.h>
+#include <unistd.h>
+
+#include "bufio.h"
+
+int
+buf_init(struct buf *buf)
+{
+	const size_t	 cap = BIO_CHUNK;
+
+	memset(buf, 0, sizeof(*buf));
+	if ((buf->buf = malloc(cap)) == NULL)
+		return (-1);
+	buf->cap = cap;
+	return (0);
+}
+
+static int
+buf_grow(struct buf *buf)
+{
+	size_t		 newcap;
+	void		*t;
+
+	newcap = buf->cap + BIO_CHUNK;
+	t = realloc(buf->buf, newcap);
+	if (t == NULL)
+		return (-1);
+	buf->buf = t;
+	buf->cap = newcap;
+	return (0);
+}
+
+int
+buf_has_line(struct buf *buf, const char *nl)
+{
+	return (memmem(buf->buf, buf->len, nl, strlen(nl)) != NULL);
+}
+
+char *
+buf_getdelim(struct buf *buf, const char *nl, size_t *len)
+{
+	uint8_t	*endl;
+	size_t	 nlen;
+
+	*len = 0;
+
+	nlen = strlen(nl);
+	if ((endl = memmem(buf->buf, buf->len, nl, nlen)) == NULL)
+		return (NULL);
+	*len = endl + nlen - buf->buf;
+	*endl = '\0';
+	return (buf->buf);
+}
+
+void
+buf_drain(struct buf *buf, size_t l)
+{
+	buf->cur = 0;
+
+	if (l >= buf->len) {
+		buf->len = 0;
+		return;
+	}
+
+	memmove(buf->buf, buf->buf + l, buf->len - l);
+	buf->len -= l;
+}
+
+void
+buf_drain_line(struct buf *buf, const char *nl)
+{
+	uint8_t		*endln;
+	size_t		 nlen;
+
+	nlen = strlen(nl);
+	if ((endln = memmem(buf->buf, buf->len, nl, nlen)) == NULL)
+		return;
+	buf_drain(buf, endln + nlen - buf->buf);
+}
+
+void
+buf_free(struct buf *buf)
+{
+	free(buf->buf);
+	memset(buf, 0, sizeof(*buf));
+}
+
+int
+bufio_init(struct bufio *bio)
+{
+	memset(bio, 0, sizeof(*bio));
+	bio->fd = -1;
+
+	if (buf_init(&bio->wbuf) == -1)
+		return (-1);
+	if (buf_init(&bio->rbuf) == -1) {
+		buf_free(&bio->wbuf);
+		return (-1);
+	}
+	return (0);
+}
+
+void
+bufio_free(struct bufio *bio)
+{
+	if (bio->ctx)
+		tls_free(bio->ctx);
+	bio->ctx = NULL;
+
+	if (bio->fd != -1)
+		close(bio->fd);
+	bio->fd = -1;
+
+	buf_free(&bio->rbuf);
+	buf_free(&bio->wbuf);
+}
+
+int
+bufio_close(struct bufio *bio)
+{
+	if (bio->ctx == NULL)
+		return (0);
+
+	switch (tls_close(bio->ctx)) {
+	case 0:
+		return 0;
+	case TLS_WANT_POLLIN:
+		errno = EAGAIN;
+		bio->wantev = BUFIO_WANT_READ;
+		return (-1);
+	case TLS_WANT_POLLOUT:
+		errno = EAGAIN;
+		bio->wantev = BUFIO_WANT_WRITE;
+		return (-1);
+	default:
+		return (-1);
+	}
+}
+
+int
+bufio_reset(struct bufio *bio)
+{
+	bufio_free(bio);
+	return (bufio_init(bio));
+}
+
+void
+bufio_set_fd(struct bufio *bio, int fd)
+{
+	bio->fd = fd;
+}
+
+int
+bufio_starttls(struct bufio *bio, const char *host, int insecure,
+    const uint8_t *cert, size_t certlen, const uint8_t *key, size_t keylen)
+{
+	struct tls_config	*conf;
+
+	if ((conf = tls_config_new()) == NULL)
+		return (-1);
+
+	if (insecure) {
+		tls_config_insecure_noverifycert(conf);
+		tls_config_insecure_noverifyname(conf);
+		tls_config_insecure_noverifytime(conf);
+	}
+
+	if (cert && tls_config_set_keypair_mem(conf, cert, certlen,
+	    key, keylen) == -1) {
+		tls_config_free(conf);
+		return (-1);
+	}
+
+	if ((bio->ctx = tls_client()) == NULL) {
+		tls_config_free(conf);
+		return (-1);
+	}
+
+	if (tls_configure(bio->ctx, conf) == -1) {
+		tls_config_free(conf);
+		return (-1);
+	}
+
+	tls_config_free(conf);
+
+	if (tls_connect_socket(bio->ctx, bio->fd, host) == -1)
+		return (-1);
+
+	return (0);
+}
+
+int
+bufio_ev(struct bufio *bio)
+{
+	short		 ev;
+
+	if (bio->wantev)
+		return (bio->wantev);
+
+	ev = BUFIO_WANT_READ;
+	if (bio->wbuf.len != 0)
+		ev |= BUFIO_WANT_WRITE;
+
+	return (ev);
+}
+
+int
+bufio_handshake(struct bufio *bio)
+{
+	if (bio->ctx == NULL) {
+		errno = EINVAL;
+		return (-1);
+	}
+
+	switch (tls_handshake(bio->ctx)) {
+	case 0:
+		return (0);
+	case TLS_WANT_POLLIN:
+		errno = EAGAIN;
+		bio->wantev = BUFIO_WANT_READ;
+		return (-1);
+	case TLS_WANT_POLLOUT:
+		errno = EAGAIN;
+		bio->wantev = BUFIO_WANT_WRITE;
+		return (-1);
+	default:
+		return (-1);
+	}
+}
+
+ssize_t
+bufio_read(struct bufio *bio)
+{
+	struct buf	*rbuf = &bio->rbuf;
+	ssize_t		 r;
+
+	assert(rbuf->cap >= rbuf->len);
+	if (rbuf->cap - rbuf->len < BIO_CHUNK) {
+		if (buf_grow(rbuf) == -1)
+			return (-1);
+	}
+
+	if (bio->ctx) {
+		r = tls_read(bio->ctx, rbuf->buf + rbuf->len,
+		    rbuf->cap - rbuf->len);
+		switch (r) {
+		case TLS_WANT_POLLIN:
+			errno = EAGAIN;
+			bio->wantev = BUFIO_WANT_READ;
+			return (-1);
+		case TLS_WANT_POLLOUT:
+			errno = EAGAIN;
+			bio->wantev = BUFIO_WANT_WRITE;
+			return (-1);
+		case -1:
+			return (-1);
+		default:
+			bio->wantev = 0;
+			rbuf->len += r;
+			return (r);
+		}
+	}
+
+	r = read(bio->fd, rbuf->buf + rbuf->len, rbuf->cap - rbuf->len);
+	if (r == -1)
+		return (-1);
+	rbuf->len += r;
+	return (r);
+}
+
+size_t
+bufio_drain(struct bufio *bio, void *d, size_t len)
+{
+	struct buf	*rbuf = &bio->rbuf;
+
+	if (len > rbuf->len)
+		len = rbuf->len;
+	memcpy(d, rbuf->buf, len);
+	buf_drain(rbuf, len);
+	return (len);
+}
+
+ssize_t
+bufio_write(struct bufio *bio)
+{
+	struct buf	*wbuf = &bio->wbuf;
+	ssize_t		 w;
+
+	if (bio->ctx) {
+		switch (w = tls_write(bio->ctx, wbuf->buf, wbuf->len)) {
+		case TLS_WANT_POLLIN:
+			errno = EAGAIN;
+			bio->wantev = BUFIO_WANT_READ;
+			return (-1);
+		case TLS_WANT_POLLOUT:
+			errno = EAGAIN;
+			bio->wantev = BUFIO_WANT_WRITE;
+			return (-1);
+		case -1:
+			return (-1);
+		default:
+			bio->wantev = 0;
+			buf_drain(wbuf, w);
+			return (w);
+		}
+	}
+
+	w = write(bio->fd, wbuf->buf, wbuf->len);
+	if (w == -1)
+		return (-1);
+	buf_drain(wbuf, w);
+	return (w);
+}
+
+const char *
+bufio_io_err(struct bufio *bio)
+{
+	if (bio->ctx)
+		return tls_error(bio->ctx);
+
+	return strerror(errno);
+}
+
+int
+bufio_compose(struct bufio *bio, const void *d, size_t len)
+{
+	struct buf	*wbuf = &bio->wbuf;
+
+	while (wbuf->cap - wbuf->len < len) {
+		if (buf_grow(wbuf) == -1)
+			return (-1);
+	}
+
+	memcpy(wbuf->buf + wbuf->len, d, len);
+	wbuf->len += len;
+	return (0);
+}
+
+int
+bufio_compose_str(struct bufio *bio, const char *str)
+{
+	return (bufio_compose(bio, str, strlen(str)));
+}
+
+int
+bufio_compose_fmt(struct bufio *bio, const char *fmt, ...)
+{
+	va_list		 ap;
+	char		*str;
+	int		 r;
+
+	va_start(ap, fmt);
+	r = vasprintf(&str, fmt, ap);
+	va_end(ap);
+
+	if (r == -1)
+		return (-1);
+	r = bufio_compose(bio, str, r);
+	free(str);
+	return (r);
+}
+
+void
+bufio_rewind_cursor(struct bufio *bio)
+{
+	bio->rbuf.cur = 0;
+}
+
+int
+bufio_get_cb(void *d)
+{
+	struct bufio	*bio = d;
+	struct buf	*rbuf = &bio->rbuf;
+
+	if (rbuf->cur >= rbuf->len)
+		return (EOF);
+	return (rbuf->buf[rbuf->cur++]);
+}
+
+int
+bufio_peek_cb(void *d)
+{
+	struct bufio	*bio = d;
+	struct buf	*rbuf = &bio->rbuf;
+
+	if (rbuf->cur >= rbuf->len)
+		return (EOF);
+	return (rbuf->buf[rbuf->cur]);
+}
blob - /dev/null
blob + c51a1017d3a424e3dc4aecc1184a9d9b4cc9636b (mode 644)
--- /dev/null
+++ lib/bufio.h
@@ -0,0 +1,75 @@
+/*
+ * This is free and unencumbered software released into the public domain.
+ *
+ * Anyone is free to copy, modify, publish, use, compile, sell, or
+ * distribute this software, either in source code form or as a compiled
+ * binary, for any purpose, commercial or non-commercial, and by any
+ * means.
+ *
+ * In jurisdictions that recognize copyright laws, the author or authors
+ * of this software dedicate any and all copyright interest in the
+ * software to the public domain. We make this dedication for the benefit
+ * of the public at large and to the detriment of our heirs and
+ * successors. We intend this dedication to be an overt act of
+ * relinquishment in perpetuity of all present and future rights to this
+ * software under copyright law.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+ * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+ * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+ * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ */
+
+struct tls;
+
+#define BIO_CHUNK	128
+struct buf {
+	uint8_t		*buf;
+	size_t		 len;
+	size_t		 cap;
+	size_t		 cur;
+};
+
+struct bufio {
+	int		 fd;
+	struct tls	*ctx;
+	int		 wantev;
+	struct buf	 wbuf;
+	struct buf	 rbuf;
+};
+
+#define	BUFIO_WANT_READ		0x1
+#define	BUFIO_WANT_WRITE	0x2
+
+int		 buf_init(struct buf *);
+int		 buf_has_line(struct buf *, const char *);
+char		*buf_getdelim(struct buf *, const char *, size_t *);
+void		 buf_drain(struct buf *, size_t);
+void		 buf_drain_line(struct buf *, const char *);
+void		 buf_free(struct buf *);
+
+int		 bufio_init(struct bufio *);
+void		 bufio_free(struct bufio *);
+int		 bufio_close(struct bufio *);
+int		 bufio_reset(struct bufio *);
+void		 bufio_set_fd(struct bufio *, int);
+int		 bufio_starttls(struct bufio *, const char *, int,
+		    const uint8_t *, size_t, const uint8_t *, size_t);
+int		 bufio_ev(struct bufio *);
+int		 bufio_handshake(struct bufio *);
+ssize_t		 bufio_read(struct bufio *);
+size_t		 bufio_drain(struct bufio *, void *, size_t);
+ssize_t		 bufio_write(struct bufio *);
+const char	*bufio_io_err(struct bufio *);
+int		 bufio_compose(struct bufio *, const void *, size_t);
+int		 bufio_compose_str(struct bufio *, const char *);
+int		 bufio_compose_fmt(struct bufio *, const char *, ...)
+		    __attribute__((__format__ (printf, 2, 3)));
+void		 bufio_rewind_cursor(struct bufio *);
+
+/* callbacks for pdjson */
+int		 bufio_get_cb(void *);
+int		 bufio_peek_cb(void *);
blob - eaca641d2887c0f5e66a410257eaf3fae004a13d
blob + 00f69c40c6fd31670fde808184134e4ac98ce8e1
--- regress/gotd/Makefile
+++ regress/gotd/Makefile
@@ -4,7 +4,8 @@ REGRESS_TARGETS=test_repo_read test_repo_read_group \
 	test_repo_read_denied_user test_repo_read_denied_group \
 	test_repo_read_bad_user test_repo_read_bad_group \
 	test_repo_write test_repo_write_empty test_request_bad \
-	test_repo_write_protected test_email_notification
+	test_repo_write_protected test_email_notification \
+	test_http_notification
 NOOBJ=Yes
 CLEANFILES=gotd.conf
 
@@ -17,6 +18,7 @@ GOTD_TEST_REPO!?=mktemp -d "$(GOTD_TEST_ROOT)/gotd-tes
 GOTD_TEST_REPO_NAME=test-repo
 GOTD_TEST_REPO_URL=ssh://${GOTD_DEVUSER}@127.0.0.1/$(GOTD_TEST_REPO_NAME)
 GOTD_TEST_SMTP_PORT=2525
+GOTD_TEST_HTTP_PORT=8000
 
 GOTD_TEST_USER?=${DOAS_USER}
 .if empty(GOTD_TEST_USER)
@@ -53,6 +55,7 @@ GOTD_TEST_ENV=GOTD_TEST_ROOT=$(GOTD_TEST_ROOT) \
 	GOTD_DEVUSER=$(GOTD_DEVUSER) \
 	GOTD_USER=$(GOTD_USER) \
 	GOTD_TEST_SMTP_PORT=$(GOTD_TEST_SMTP_PORT) \
+	GOTD_TEST_HTTP_PORT=$(GOTD_TEST_HTTP_PORT) \
 	HOME=$(GOTD_TEST_USER_HOME) \
 	PATH=$(GOTD_TEST_USER_HOME)/bin:$(PATH)
 
@@ -169,6 +172,19 @@ start_gotd_email_notification: ensure_root
 	@$(GOTD_TRAP); $(GOTD_START_CMD)
 	@$(GOTD_TRAP); sleep .5
 
+start_gotd_http_notification: ensure_root
+	@echo 'listen on "$(GOTD_SOCK)"' > $(PWD)/gotd.conf
+	@echo "user $(GOTD_USER)" >> $(PWD)/gotd.conf
+	@echo 'repository "test-repo" {' >> $(PWD)/gotd.conf
+	@echo '    path "$(GOTD_TEST_REPO)"' >> $(PWD)/gotd.conf
+	@echo '    permit rw $(GOTD_DEVUSER)' >> $(PWD)/gotd.conf
+	@echo '    notify {' >> $(PWD)/gotd.conf
+	@echo '         url "http://localhost:${GOTD_TEST_HTTP_PORT}/"' >> $(PWD)/gotd.conf
+	@echo "    }" >> $(PWD)/gotd.conf
+	@echo "}" >> $(PWD)/gotd.conf
+	@$(GOTD_TRAP); $(GOTD_START_CMD)
+	@$(GOTD_TRAP); sleep .5
+
 prepare_test_repo: ensure_root
 	@chown ${GOTD_USER} "${GOTD_TEST_REPO}"
 	@su -m ${GOTD_USER} -c 'env $(GOTD_TEST_ENV) sh ./prepare_test_repo.sh'
@@ -241,4 +257,9 @@ test_email_notification: prepare_test_repo start_gotd_
 		'env $(GOTD_TEST_ENV) sh ./email_notification.sh'
 	@$(GOTD_STOP_CMD) 2>/dev/null
 
+test_http_notification: prepare_test_repo start_gotd_http_notification
+	@-$(GOTD_TRAP); su -m ${GOTD_TEST_USER} -c \
+		'env $(GOTD_TEST_ENV) sh ./http_notification.sh'
+	@$(GOTD_STOP_CMD) 2>/dev/null
+
 .include <bsd.regress.mk>
blob - /dev/null
blob + 7dbafb4ae773efae234479b2c918d895dce5ac1e (mode 755)
--- /dev/null
+++ regress/gotd/http-server
@@ -0,0 +1,90 @@
+#!/usr/bin/env perl
+#
+# Copyright (c) 2024 Omar Polo <op@openbsd.org>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+use v5.36;
+use IPC::Open2;
+use Getopt::Long qw(:config bundling);
+
+my $port = 8000;
+
+GetOptions("p:i" => \$port)
+    or die("usage: $0 [-p port]\n");
+
+my $pid = open2(my $out, my $in, 'nc', '-l', 'localhost', $port);
+
+my $clen;
+while (<$out>) {
+	local $/ = "\r\n";
+
+	chomp;
+	say;
+
+	last if /^$/;
+
+	if (m/^POST/) {
+		die "bad http request" unless m,^POST / HTTP/1.1$,;
+		next;
+	}
+
+	if (m/^Host:/) {
+		die "bad Host header" unless /^Host: localhost:$port$/;
+		next;
+	}
+
+	if (m/^Content-Type/) {
+		die "bad content-type header"
+		    unless m,Content-Type: application/json$,;
+		next;
+	}
+
+	if (m/^Content-Length/) {
+		die "double content-length" if defined $clen;
+		die "bad content-length header"
+		    unless m/Content-Length: (\d+)$/;
+		$clen = $1;
+		next;
+	}
+
+	if (m/Connection/) {
+		die "bad connection header"
+		    unless m/Connection: close$/;
+		next;
+	}
+}
+
+die "no Content-Length header" unless defined $clen;
+
+while ($clen != 0) {
+	my $len = $clen;
+	$len = 512 if $clen > 512;
+
+	my $r = read($out, my $buf, $len);
+	$clen -= $r;
+
+	print $buf;
+}
+say "";
+
+print $in "HTTP/1.1 200 OK\r\n";
+print $in "Content-Length: 0\r\n";
+print $in "Connection: close\r\n";
+print $in "\r\n";
+
+close $in;
+close $out;
+
+waitpid($pid, 0);
+exit $? >> 8;
blob - /dev/null
blob + 5bd5779ff7fd7e13571b71c414ee5f1bb1f6dc2e (mode 644)
--- /dev/null
+++ regress/gotd/http_notification.sh
@@ -0,0 +1,234 @@
+#!/bin/sh
+#
+# Copyright (c) 2024 Omar Polo <op@openbsd.org>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+. ../cmdline/common.sh
+. ./common.sh
+
+test_file_changed() {
+	local testroot=`test_init file_changed 1`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got checkout -q $testroot/repo-clone $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	echo "change alpha" > $testroot/wt/alpha
+	(cd $testroot/wt && got commit -m 'make changes' > /dev/null)
+	local commit_id=`git_show_head $testroot/repo-clone`
+	local author_time=`git_show_author_time $testroot/repo-clone`
+
+	timeout 5 ./http-server -p $GOTD_TEST_HTTP_PORT \
+	    > $testroot/stdout &
+
+	got send -b main -q -r $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	wait %1 # wait for the http "server"
+
+	d=`date -u -r $author_time +"%a %b %e %X %Y UTC"`
+
+	cat <<-EOF > $testroot/stdout.expected
+	POST / HTTP/1.1
+	Host: localhost:${GOTD_TEST_HTTP_PORT}
+	Content-Type: application/json
+	Content-Length: 224
+	User-Agent: got-notify-http/${GOT_VERSION_STR}
+	Connection: close
+
+	{"notifications":[{"short":false,"id":"$commit_id","author":"$GOT_AUTHOR","date":"$d","message":"make changes\n","diffstat":{},"changes":{}}]}
+	EOF
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_many_commits_not_summarized() {
+	local testroot=`test_init many_commits_not_summarized 1`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got checkout -q $testroot/repo-clone $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	for i in `seq 1 24`; do
+		echo "alpha $i" > $testroot/wt/alpha
+		(cd $testroot/wt && got commit -m 'make changes' > /dev/null)
+		local commit_id=`git_show_head $testroot/repo-clone`
+		local author_time=`git_show_author_time $testroot/repo-clone`
+		d=`date -u -r $author_time +"%a %b %e %X %Y UTC"`
+		set -- "$@" "$commit_id $d"
+	done
+
+	timeout 5 ./http-server -p "$GOTD_TEST_HTTP_PORT" \
+	    > $testroot/stdout &
+
+	got send -b main -q -r $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	wait %1 # wait for the http "server"
+
+	cat <<-EOF > $testroot/stdout.expected
+	POST / HTTP/1.1
+	Host: localhost:${GOTD_TEST_HTTP_PORT}
+	Content-Type: application/json
+	Content-Length: 4939
+	User-Agent: got-notify-http/${GOT_VERSION_STR}
+	Connection: close
+
+	EOF
+
+	printf '{"notifications":[' >> $testroot/stdout.expected
+	comma=""
+	for i in `seq 1 24`; do
+		s=`pop_idx $i "$@"`
+		commit_id=$(echo $s | cut -d' ' -f1)
+		commit_time=$(echo $s | sed -e "s/^$commit_id //g")
+		printf '%s{"short":false,"id":"%s","author":"%s","date":"%s","message":"%s","diffstat":{},"changes":{}}' \
+			"$comma" "$commit_id" "$GOT_AUTHOR" "$commit_time" "make changes\n"
+		comma=","
+	done >> $testroot/stdout.expected
+	echo "]}" >> $testroot/stdout.expected
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_many_commits_summarized() {
+	local testroot=`test_init many_commits_summarized 1`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got checkout -q $testroot/repo-clone $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	for i in `seq 1 51`; do
+		echo "alpha $i" > $testroot/wt/alpha
+		(cd $testroot/wt && got commit -m 'make changes' > /dev/null)
+		local commit_id=`git_show_head $testroot/repo-clone`
+		local short_commit_id=`trim_obj_id 33 $commit_id`
+		local author_time=`git_show_author_time $testroot/repo-clone`
+		d=`date -u -r $author_time +"%G-%m-%d"`
+		set -- "$@" "$short_commit_id $d"
+	done
+
+	timeout 5 ./http-server -p "$GOTD_TEST_HTTP_PORT" \
+	    > $testroot/stdout &
+
+	got send -b main -q -r $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	wait %1 # wait for the http "server"
+
+	cat <<-EOF > $testroot/stdout.expected
+	POST / HTTP/1.1
+	Host: localhost:${GOTD_TEST_HTTP_PORT}
+	Content-Type: application/json
+	Content-Length: 4864
+	User-Agent: got-notify-http/${GOT_VERSION_STR}
+	Connection: close
+
+	EOF
+
+	printf '{"notifications":[' >> $testroot/stdout.expected
+	comma=""
+	for i in `seq 1 51`; do
+		s=`pop_idx $i "$@"`
+		commit_id=$(echo $s | cut -d' ' -f1)
+		commit_time=$(echo $s | sed -e "s/^$commit_id //g")
+		printf '%s{"short":true,"id":"%s","author":"%s","date":"%s","message":"%s"}' \
+			"$comma" "$commit_id" "$GOT_AUTHOR_8" \
+			"$commit_time" "make changes"
+		comma=","
+	done >> $testroot/stdout.expected
+	echo "]}" >> $testroot/stdout.expected
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_parseargs "$@"
+run_test test_file_changed
+run_test test_many_commits_not_summarized
+run_test test_many_commits_summarized