Commit Diff


commit - f9e6537008c7344a6107b88bc19c5f293cacd793
commit + 5565365ce71d431c522f98b121706b13c4bf2d22
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,19 +1538,28 @@ 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);
+		ret = -1;
+		goto done;
+	}
+
+	if ((user != NULL && password == NULL) ||
+	    (user == NULL && password != NULL)) {
+		yyerror("missing username or password");
 		ret = -1;
 		goto done;
 	}
@@ -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