💾 Archived View for gmn.clttr.info › sources › cgmnlm.git › tree › src › gmnlm.c.txt captured on 2023-01-29 at 05:04:53.

View Raw

More Information

⬅️ Previous capture (2022-06-03)

➡️ Next capture (2023-03-20)

🚧 View Differences

-=-=-=-=-=-=-

#include <assert.h>
#include <bearssl.h>
#include <ctype.h>
#include <errno.h>
#include <fcntl.h>
#include <getopt.h>
#include <gmni/certs.h>
#include <gmni/gmni.h>
#include <gmni/tofu.h>
#include <gmni/url.h>
#include <libgen.h>
#include <limits.h>
#include <regex.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <termios.h>
#include <unistd.h>
#include "util.h"

#define ANSI_COLOR_RED     "\x1b[91m"
#define ANSI_COLOR_GREEN   "\x1b[92m"
#define ANSI_COLOR_YELLOW  "\x1b[93m"
#define ANSI_COLOR_BLUE    "\x1b[94m"
#define ANSI_COLOR_MAGENTA "\x1b[35m"
#define ANSI_COLOR_LMAGENTA "\x1b[95m"
#define ANSI_COLOR_CYAN    "\x1b[36m"
#define ANSI_COLOR_LCYAN   "\x1b[96m"
#define ANSI_COLOR_GRAY    "\x1b[37m"
#define ANSI_COLOR_RESET   "\x1b[0m"

struct link {
	char *url;
	struct link *next;
};

struct history {
	char *url;
	struct history *prev, *next;
};

#define REDIRS_UNLIMITED -1
#define REDIRS_ASK -2

struct browser {
	bool pagination, unicode, alttext, autoopen;
	int max_width;
	int max_redirs;
	struct gemini_options opts;
	struct gemini_tofu tofu;
	enum tofu_action tofu_mode;

	FILE *tty;
	char *meta;
	char *plain_url;
	char *page_title;
	struct Curl_URL *url;
	struct link *links;
	struct history *history;
	bool running;

	bool searching;
	regex_t regex;
};

enum prompt_result {
	PROMPT_AGAIN,
	PROMPT_MORE,
	PROMPT_QUIT,
	PROMPT_ANSWERED,
	PROMPT_NEXT,
};

const char *default_bookmarks =
	"# Welcome to cgmnlm\n\n"
	"Links:\n\n"
	"=> gemini://gmn.clttr.info/cgmnln.gmi The colorful gemini line mode client\n"
	"=> gemini://gemini.circumlunar.space The gemini protocol\n"
	"=> gemini://geminispace.info/search/ search in geminispace\n\n"
	"This file can be found at %s and may be edited at your pleasure.\n\n"
	"Bookmarks:\n"
	;

const char *help_msg =
	"The following commands are available:\n\n"
	"<Enter>\t\tRead more lines (if available)\n"
	"<url>\t\tGo to url\n"
	"[N]\t\tFollow Nth link\n"
	"p[N]\t\tPrint URL of Nth link\n"
	"e[N]\t\tSend URL of current page or Nth link to external default program\n"
	"t[N]\t\tDownload content of current page or Nth link to a temporary file\n"
	"b[N]\t\tJump back N entries in history, default 1\n"
	"f[N]\t\tJump forward N entries in history, default 1\n"
	"u\t\tNavigate one path element up\n"
	"i\r\t\tShow MIME type parameters\n"
	"H\t\tView all page history\n"
	"m [title]\t\tSave bookmark for current page (uses first header as name if title is omitted)\n"
	"M\t\tBrowse bookmarks\n"
	"k\t\tRemove bookmark for current page\n"
	"r\t\tReload the page\n"
	"s\t\tSearch via geminispace.info\n"
	"l\t\tSearch backlinks to current page via geminispace.info\n"
	"/<text>\t\tSearch for text (POSIX regular expression)\n"
	"n\t\tJump to next search match\n"
	"d[N] [path]\tDownload page, or Nth link, to path\n"
	"[N]|<prog>\tPipe page, or Nth link, into program\n"
	"a\t\tToggle usage of alt text instead of preformatted text\n"
	"q\t\tQuit\n"
	"\n"
	"[N] must be replaced with a number >= 0\n"
	"\n"
	;

static void
usage(const char *argv_0)
{
	fprintf(stderr, "usage: %s [-PUAT] [-j mode] [-R redirs] [-W width] [gemini://...]\n", argv_0);
}

static void
history_free(struct history *history)
{
	if (!history) {
		return;
	}
	history_free(history->next);
	free(history->url);
	free(history);
}

static bool
set_url(struct browser *browser, char *new_url, struct history **history)
{
	if (curl_url_set(browser->url, CURLUPART_URL, new_url, 0) != CURLUE_OK) {
		fprintf(stderr, "Error: invalid URL\n");
		return false;
	}
	if (browser->plain_url != NULL) {
		free(browser->plain_url);
	}
	curl_url_get(browser->url, CURLUPART_URL, &browser->plain_url, 0);
	if (history) {
		struct history *next = calloc(1, sizeof(struct history));
		curl_url_get(browser->url, CURLUPART_URL, &next->url, 0);
		next->prev = *history;
		if (*history) {
			if ((*history)->next) {
				history_free((*history)->next);
			}
			(*history)->next = next;
		}
		*history = next;
	}
	return true;
}

static char *
get_data_pathfmt()
{
	const struct pathspec paths[] = {
		{.var = "GMNIDATA", .path = "/%s"},
		{.var = "XDG_DATA_HOME", .path = "/gmni/%s"},
		{.var = "HOME", .path = "/.local/share/gmni/%s"}
	};
	return getpath(paths, sizeof(paths) / sizeof(paths[0]));
}

static char *
trim_ws(char *in)
{
	while (*in && isspace(*in)) ++in;
	return in;
}

static void
save_bookmark(struct browser *browser, const char *title)
{
	char *path_fmt = get_data_pathfmt();
	static char path[PATH_MAX+1];
	static char dname[PATH_MAX+1];
	size_t n;

	n = snprintf(path, sizeof(path), path_fmt, "bookmarks.gmi");
	free(path_fmt);
	assert(n < sizeof(path));
	posix_dirname(path, dname);
	if (mkdirs(dname, 0755) != 0) {
		fprintf(stderr, "Error creating directory %s: %s\n",
				dname, strerror(errno));
		return;
	}

	FILE *f = fopen(path, "a");
	if (!f) {
		fprintf(stderr, "Error opening %s for writing: %s\n",
				path, strerror(errno));
		return;
	}

	fprintf(f, "=> %s%s%s\n", browser->plain_url,
		title ? " " : "", title ? title : "");
	fclose(f);

	fprintf(browser->tty, "Bookmark saved: %s\n",
		title ? title : browser->plain_url);
}

static void
remove_bookmark(struct browser *browser)
{
	char *path_fmt = get_data_pathfmt();
	static char path[PATH_MAX+1];
	snprintf(path, sizeof(path), path_fmt, "bookmarks.gmi");
	free(path_fmt);

	static char tempfile[PATH_MAX+2];
	snprintf(tempfile, sizeof(tempfile), "%s2", path);
	FILE *fi = fopen(path, "r");
	FILE *fo = fopen(tempfile, "w");
	if(fi == NULL) {
		fprintf(stderr, "Bookmark file not available!\n");
		return;
	}
	if(fo == NULL) {
		fprintf(stderr, "tempfile not available!\n");
		return;
	}
	
	char *line = NULL;
	size_t len = 0;
	size_t n = 0;

	static char url[1024];
	n = snprintf(url, sizeof(url), "=> %s ", browser->plain_url);
	while(getline(&line, &len, fi) != -1) {
		if (strncmp(line, url, n)==0) {
			fprintf(browser->tty, "Bookmark removed!\n");
		} else {
			fprintf(fo, "%s", line);
		}
	}

	fclose(fi);
	fclose(fo);
	free(line);
	if ( rename(tempfile, path) != 0) {
		fprintf(browser->tty, "Failed to update bookmarks: %s\n", strerror(errno));
	}
}

static void
open_bookmarks(struct browser *browser)
{
	char *path_fmt = get_data_pathfmt();
	static char path[PATH_MAX+1];
	static char dname[PATH_MAX+1];
	size_t n;

	n = snprintf(path, sizeof(path), path_fmt, "bookmarks.gmi");
	free(path_fmt);

	assert(n < sizeof(path));
	posix_dirname(path, dname);
	if (mkdirs(dname, 0755) != 0) {
		fprintf(stderr, "Error creating directory %s: %s\n",
				dname, strerror(errno));
		return;
	}

	struct stat buf;
	if (stat(path, &buf) == -1 && errno == ENOENT) {
		// TOCTOU, but we almost certainly don't care
		FILE *f = fopen(path, "a");
		if (f == NULL) {
			fprintf(stderr, "Error opening %s for writing: %s\n",
					path, strerror(errno));
			return;
		}
		fprintf(f, default_bookmarks, path);
		fclose(f);
	}

	static char url[PATH_MAX+1+7];
	snprintf(url, sizeof(url), "file://%s", path);
	set_url(browser, url, &browser->history);
}

static void
print_media_parameters(FILE *out, char *params)
{
	if (params == NULL) {
		fprintf(out, "No media parameters\n");
		return;
	}
	for (char *param = strtok(params, ";"); param;
			param = strtok(NULL, ";")) {
		char *value = strchr(param, '=');
		if (value == NULL) {
			fprintf(out, "Invalid media type parameter '%s'\n",
				trim_ws(param));
			continue;
		}
		*value = 0;
		fprintf(out, "%s: ", trim_ws(param));
		*value++ = '=';
		if (*value != '"') {
			fprintf(out, "%s\n", value);
			continue;
		}
		while (value++) {
			switch (*value) {
			case '\0':
				if ((value = strtok(NULL, ";")) != NULL) {
					fprintf(out, ";%c", *value);
				}
				break;
			case '"':
				value = NULL;
				break;
			case '\\':
				if (value[1] == '\0') {
					break;
				}
				value++;
				/* fallthrough */
			default:
				putc(*value, out);
			}
		}
		putc('\n', out);
	}
}

static char *
get_input(const struct gemini_response *resp, FILE *source)
{
	int r = 0;
	struct termios attrs;
	bool tty = fileno(source) != -1 && isatty(fileno(source));
	char *input = NULL;
	if (tty) {
		fprintf(stderr, "%s: ", resp->meta);
		if (resp->status == GEMINI_STATUS_SENSITIVE_INPUT) {
			r = tcgetattr(fileno(source), &attrs);
			struct termios new_attrs;
			r = tcgetattr(fileno(source), &new_attrs);
			if (r != -1) {
				new_attrs.c_lflag &= ~ECHO;
				tcsetattr(fileno(source), TCSANOW, &new_attrs);
			}
		}
	}
	size_t s = 0;
	ssize_t n = getline(&input, &s, source);
	if (n == -1) {
		fprintf(stderr, "Error reading input: %s\n",
			feof(source) ? "EOF" : strerror(ferror(source)));
		return NULL;
	}
	input[n - 1] = '\0'; // Drop LF
	if (tty && resp->status == GEMINI_STATUS_SENSITIVE_INPUT && r != -1) {
		attrs.c_lflag &= ~ECHO;
		tcsetattr(fileno(source), TCSANOW, &attrs);
	}
	return input;
}

static bool
has_suffix(char *str, char *suff)
{
	size_t suffl = strlen(suff);
	size_t strl = strlen(str);
	if (strl < suffl) {
		return false;
	}
	return strcmp(&str[strl - suffl], suff) == 0;
}

static void
pipe_resp(FILE *out, struct gemini_response resp, char *cmd) {
	char buf[BUFSIZ];
	int pfd[2];
	if (pipe(pfd) == -1) {
		perror("pipe");
		return;
	}
	pid_t pid;
	switch ((pid = fork())) {
	case -1:
		perror("fork");
		return;
	case 0:
		close(pfd[1]);
		dup2(pfd[0], STDIN_FILENO);
		close(pfd[0]);
		execlp("sh", "sh", "-c", cmd, NULL);
		perror("exec");
		_exit(1);
	}
	close(pfd[0]);
	FILE *f = fdopen(pfd[1], "w");
	// XXX: may affect history, do we care?
	for (int n = 1; n > 0;) {
		if (resp.sc) {
			n = br_sslio_read(&resp.body, buf, BUFSIZ);
		} else {
			n = read(resp.fd, buf, BUFSIZ);
		}
		if (n < 0) {
			n = 0;
		}
		ssize_t w = 0;
		while (w < (ssize_t)n) {
			ssize_t x = fwrite(&buf[w], 1, n - w, f);
			if (ferror(f)) {
				fprintf(stderr, "Error: write: %s\n",
					strerror(errno));
				return;
			}
			w += x;
		}
	}
	fclose(f);
	int status;
	waitpid(pid, &status, 0);
	if (status != 0) {
		fprintf(out, "Command exited %d\n", status);
	}
}

static enum gemini_result
do_requests(struct browser *browser, struct gemini_response *resp)
{
	int nredir = 0;
	bool requesting = true;
	enum gemini_result res;

	char *scheme;
	CURLUcode uc = curl_url_get(browser->url,
		CURLUPART_SCHEME, &scheme, 0);
	assert(uc == CURLUE_OK); // Invariant

	char *host = NULL;
	struct gmni_client_certificate client_cert = {0};
	const struct pathspec paths[] = {
		{.var = "GMNIDATA", .path = "/certs/%s.%s"},
		{.var = "XDG_DATA_HOME", .path = "/gmni/certs/%s.%s"},
		{.var = "HOME", .path = "/.local/share/gmni/certs/%s.%s"}
	};
	char *path_fmt = getpath(paths, sizeof(paths) / sizeof(paths[0]));
	char certpath[PATH_MAX+1], keypath[PATH_MAX+1];
	size_t n = 0;

	if (strcmp(scheme, "gemini") == 0) {
		CURLUcode uc = curl_url_get(browser->url,
			CURLUPART_HOST, &host, 0);
		assert(uc == CURLUE_OK);

		n = snprintf(certpath, sizeof(certpath), path_fmt, host, "crt");
		assert(n < sizeof(certpath));
		FILE *certin = fopen(certpath, "r");
		if (certin) {
			n = snprintf(keypath, sizeof(keypath), path_fmt, host, "key");
			assert(n < sizeof(keypath));

			FILE *skin = fopen(keypath, "r");
			if (gmni_ccert_load(&client_cert, certin, skin)) {
				browser->opts.client_cert = NULL;
				fprintf(stderr, "Unable to load client certificate for host %s", host);
			} else {
				browser->opts.client_cert = &client_cert;
			}
		} else {
			browser->opts.client_cert = NULL;
		}
	}

	while (requesting) {
		if (strcmp(scheme, "file") == 0) {
			requesting = false;

			char *path;
			uc = curl_url_get(browser->url,
				CURLUPART_PATH, &path, 0);
			if (uc != CURLUE_OK) {
				resp->status = GEMINI_STATUS_BAD_REQUEST;
				break;
			}

			int fd = open(path, O_RDONLY);
			if (fd < 0) {
				resp->status = GEMINI_STATUS_NOT_FOUND;
				// Make sure members of resp evaluate to false,
				// so that gemini_response_finish does not try
				// to free them.
				resp->sc = NULL;
				resp->meta = NULL;
				resp->fd = -1;
				free(path);
				break;
			}

			if (has_suffix(path, ".gmi") || has_suffix(path, ".gemini")) {
				resp->meta = strdup("text/gemini");
			} else if (has_suffix(path, ".txt")) {
				resp->meta = strdup("text/plain");
			} else {
				resp->meta = strdup("application/x-octet-stream");
			}
			free(path);
			resp->status = GEMINI_STATUS_SUCCESS;
			resp->fd = fd;
			resp->sc = NULL;
			res = GEMINI_OK;
			goto out;
		}

		res = gemini_request(browser->plain_url, &browser->opts,
				&browser->tofu, resp);
		if (res != GEMINI_OK) {
			fprintf(stderr, "Error: %s\n", gemini_strerr(res, resp));
			requesting = false;
			resp->status = 70 + res;
			break;
		}

		char *input;
		switch (gemini_response_class(resp->status)) {
		case GEMINI_STATUS_CLASS_INPUT:
			input = get_input(resp, browser->tty);
			if (!input) {
				requesting = false;
				break;
			}
			if (input[0] == '\0' && browser->history->prev) {
				free(input);
				browser->history = browser->history->prev;
				set_url(browser, browser->history->url, NULL);
				break;
			}

			char *new_url = gemini_input_url(
				browser->plain_url, input);
			free(input);
			assert(new_url);
			set_url(browser, new_url,
				resp->status == GEMINI_STATUS_SENSITIVE_INPUT ?
				NULL : &browser->history);
			free(new_url);
			break;
		case GEMINI_STATUS_CLASS_REDIRECT:
			if (browser->max_redirs == REDIRS_ASK) {
again:
				fprintf(browser->tty,
					"The host %s is redirecting to:\n"
					"%s\n\n"
					"[f]ollow redirect; [a]bort\n"
					"=> ", host, resp->meta);

				size_t sz = 0;
				char *line = NULL;
				if (getline(&line, &sz, browser->tty) == -1) {
					free(line);
					requesting = false;
					break;
				}
				if (line[1] != '\n') {
					free(line);
					goto again;
				}

				char c = line[0];
				free(line);

				if (c == 'a') {
					requesting = false;
					break;
				} else if (c != 'f') {
					goto again;
				}
			} else if (browser->max_redirs != REDIRS_UNLIMITED
					&& ++nredir >= browser->max_redirs) {
				requesting = false;
				fprintf(stderr, "Error: maximum redirects (%d) exceeded\n",
					browser->max_redirs);
				break;
			}
			set_url(browser, resp->meta, NULL);
			break;
		case GEMINI_STATUS_CLASS_CLIENT_CERTIFICATE_REQUIRED:
			requesting = false;
			assert(host);
			n = snprintf(certpath, sizeof(certpath), path_fmt, host, "crt");
			assert(n < sizeof(certpath));
			n = snprintf(keypath, sizeof(keypath), path_fmt, host, "key");
			char dname[PATH_MAX + 1];
			posix_dirname(certpath, dname);
			if (mkdirs(dname, 0755) != 0) {
				fprintf(stderr, "Error creating directory %s: %s\n",
						dname, strerror(errno));
				break;
			}
			assert(n < sizeof(keypath));
			fprintf(stderr, "The server requested a client certificate.\n"
				"Presently, this process is not automated.\n"
				"The following OpenSSL command will generate a certificate for this host:\n\n"
				"openssl req -x509 -newkey rsa:4096 \\\n\t-keyout %s \\\n\t-out %s \\\n\t-days 36500 -nodes\n\n"
				"Use the 'r' command to reload the page after creating this certificate.\n",
				keypath, certpath);
			break;
		case GEMINI_STATUS_CLASS_TEMPORARY_FAILURE:
		case GEMINI_STATUS_CLASS_PERMANENT_FAILURE:
			requesting = false;
			fprintf(stderr, "Server returned %s %d %s\n",
				resp->status / 10 == 4 ?
				"TEMPORARY FAILURE" : "PERMANENT FAILURE",
				resp->status, resp->meta);
			break;
		case GEMINI_STATUS_CLASS_SUCCESS:
			goto out;
		}

		if (requesting) {
			gemini_response_finish(resp);
		}
	}

out:
	if (client_cert.key) {
		free(client_cert.key);
	}
	free(path_fmt);
	free(scheme);
	free(host);
	return res;
}

static enum prompt_result
do_prompts(const char *prompt, struct browser *browser)
{
	enum prompt_result result = PROMPT_AGAIN;
	fprintf(browser->tty, "%s", prompt);

	size_t l = 0;
	
	char curr_url[1024] = {0};
	char save_url[1024] = {0};
	
	char *in = NULL;
	ssize_t n = getline(&in, &l, browser->tty);
	if (n == -1 && feof(browser->tty)) {
		fputc('\n', browser->tty);
		result = PROMPT_QUIT;
		goto exit;
	}

	in[n - 1] = 0; // Remove LF
	char *endptr;

	CURLU *url = curl_url();
	bool isurl = curl_url_set(url, CURLUPART_URL, in, 0) == CURLUE_OK;
	curl_url_cleanup(url);
	if (isurl) {
		set_url(browser, in, &browser->history);
		result = PROMPT_ANSWERED;
		goto exit;
	}

	int historyhops = 1;
	int r;
	switch (in[0]) {
	case '\0':
		result = PROMPT_MORE;
		goto exit;
	case '.':
		break;
	case 'q':
		if (in[1]) {
			fprintf(stderr, "Error: unrecognized command.\n");
			goto exit;
		}
		result = PROMPT_QUIT;
		goto exit;
	case 'b':
		if (in[1] && isdigit(in[1])) {
			historyhops =(int)strtol(in+1, &endptr, 10);
			if (endptr[0]) {
				fprintf(stderr, "Error: invalid argument.\n");
				goto exit;
			}
		} else if (in[1]) {
			fprintf(stderr, "Error: invalid argument.\n");
			goto exit;
		}
		while (historyhops > 0) {
			if (browser->history->prev) {
				browser->history = browser->history->prev;
			}
			historyhops--;
		}
		set_url(browser, browser->history->url, NULL);
		result = PROMPT_ANSWERED;
		goto exit;
	case 's':
		if (in[1]) break;
		set_url(browser, "gemini://geminispace.info/search", &browser->history);
		result = PROMPT_ANSWERED;
		goto exit;
	case 'a':
		browser->alttext = !browser->alttext;
		fprintf(browser->tty, "Alttext instead of preformatted block is now %s\n\n", browser->alttext ? "ENABLED" : "DISABLED");
		result = PROMPT_AGAIN;
		goto exit;
	case 'l':
		snprintf(curr_url, sizeof(curr_url), "gemini://geminispace.info/backlinks?%s", browser->plain_url);
		set_url(browser, curr_url, &browser->history);
		result = PROMPT_ANSWERED;
		goto exit;
	case 'f':
		if (in[1] && isdigit(in[1])) {
			historyhops =(int)strtol(in+1, &endptr, 10);
			if (endptr[0]) {
				fprintf(stderr, "Error: invalid argument.\n");
				goto exit;
			}
		} else if (in[1]) {
			fprintf(stderr, "Error: invalid argument.\n");
			goto exit;
		}
		while (historyhops > 0) {
			if (browser->history->next) {
				browser->history = browser->history->next;
			}
			historyhops--;
		}
		set_url(browser, browser->history->url, NULL);
		result = PROMPT_ANSWERED;
		goto exit;
	case 'u':;
		int keep = 0;
		int len = strlen(browser->plain_url);
		for (int i=0; i<len; i++)
		{
			// ignore trailing / on uri path
			if (browser->plain_url[i] == '/' && i != len-1) {
				keep = i;
			}   
		}
		if (keep > 9) {
			strncpy(curr_url , browser->plain_url, keep+1);
			set_url(browser, curr_url, &browser->history);
		}
		result = PROMPT_ANSWERED;
		goto exit;
	case 'H':
		if (in[1]) {
			fprintf(stderr, "Error: unrecognized command.\n");
			goto exit;
		}
		struct history *cur = browser->history;
		int hist_count = 0;
		while (cur->prev) {
			cur = cur->prev;
			hist_count++;
		}
		while (cur != browser->history) {
			fprintf(browser->tty, "b%-3i %s\n", hist_count--, cur->url);
			cur = cur->next;
		}
		fprintf(browser->tty, "*    %s\n", cur->url);
		cur = cur->next;
		while (cur) {
			fprintf(browser->tty, "f%-3i %s\n", ++hist_count, cur->url);
			cur = cur->next;
		}
		goto exit;
	case 'm':
		if (in[1] != '\0' && !isspace(in[1])) {
			fprintf(stderr, "Error: unrecognized command.\n");
			goto exit;
		}
		char *title = in[1] ? &in[1] : browser->page_title;
		save_bookmark(browser, title ? trim_ws(title) : title);
		goto exit;
	case 'M':
		if (in[1]) {
			fprintf(stderr, "Error: unrecognized command.\n");
			goto exit;
		}
		open_bookmarks(browser);
		result = PROMPT_ANSWERED;
		goto exit;
	case 'k':
		if (in[1]) break;
		remove_bookmark(browser);
		result = PROMPT_AGAIN;
		goto exit;
	case 'e':
	case 't':
		strncpy(&save_url[0], browser->plain_url, sizeof(save_url)-1);
		if (!in[1]) {
			strncpy(&curr_url[0], browser->plain_url, sizeof(curr_url)-1);
		} else {
			struct link *link = browser->links;
			int linksel = (int)strtol(in+1, &endptr, 10);
			if (!endptr[0] && linksel >= 0) {
				while (linksel > 0 && link) {
					link = link->next;
					--linksel;
				}
				if (!link) {
					fprintf(stderr, "Error: no such link.\n");
				} else {
					strncpy(&curr_url[0], link->url, sizeof(curr_url)-1);
				}
			}
		}
		if (curr_url[0]) {
			fprintf(browser->tty, "=> %s\n", curr_url);
			char *tempfile;
			tempfile = tmpnam(NULL);
			if (in[0] == 't') {
				struct gemini_response resp;
				set_url(browser, curr_url, NULL);
				enum gemini_result res = do_requests(browser, &resp);
				if (res != GEMINI_OK) {
					fprintf(stderr, "Error: %s\n",
					gemini_strerr(res, &resp));
				} else {
					download_resp(browser->tty, resp, tempfile, curr_url);
				}
				gemini_response_finish(&resp);
				set_url(browser, save_url, NULL);
			}
			if (in[0] == 'e' || browser->autoopen) {				
				char target[1050];
				snprintf(target, sizeof(target), "xdg-open %s >/dev/null 2>&1", in[0] == 't' ? tempfile : curr_url);
				if ( !system(target) ) fprintf(browser->tty, "Link send to xdg-open\n");
			}
			fprintf(browser->tty, "\n");
		}
		result = PROMPT_AGAIN;
		goto exit;
	case '/':
		if (!in[1]) break;
		if ((r = regcomp(&browser->regex, &in[1], REG_EXTENDED)) != 0) {
			static char buf[1024];
			r = regerror(r, &browser->regex, buf, sizeof(buf));
			assert(r < (int)sizeof(buf));
			fprintf(stderr, "Error: %s\n", buf);
		} else {
			browser->searching = true;
			result = PROMPT_ANSWERED;
		}
		goto exit_re;
	case 'n':
		if (in[1]) {
			fprintf(stderr, "Error: unrecognized command.\n");
			goto exit;
		}
		if (browser->searching) {
			result = PROMPT_NEXT;
			goto exit_re;
		} else {
			fprintf(stderr, "Cannot move to next result; we are not searching for anything\n");
			goto exit;
		}
	case 'p':
		if (!in[1]) {
			fprintf(stderr, "Error: missing argument.\n");
			goto exit;
		} else if (!isdigit(in[1])) {
			fprintf(stderr, "Error: invalid argument.\n");
			goto exit;
		}
		struct link *link = browser->links;
		int linksel = (int)strtol(in+1, &endptr, 10);
		if (!endptr[0] && linksel >= 0) {
			while (linksel > 0 && link) {
				link = link->next;
				--linksel;
			}

			if (!link) {
				fprintf(stderr, "Error: no such link.\n");
			} else {
				fprintf(browser->tty, "=> %s\n", link->url);
				goto exit;
			}
		} else {
			fprintf(stderr, "Error: invalid argument.\n");
		}
		goto exit;
	case 'r':
		if (in[1]) {
			fprintf(stderr, "Error: unrecognized command.\n");
			goto exit;
		}
		result = PROMPT_ANSWERED;
		goto exit;
	case 'i':
		if (in[1]) {
			fprintf(stderr, "Error: unrecognized command.\n");
			goto exit;
		}
		print_media_parameters(browser->tty, browser->meta
				? strchr(browser->meta, ';') : NULL);
		goto exit;
	case 'd':
		endptr = &in[1];
		char *d_url = browser->plain_url;
		if (in[1] != '\0' && !isspace(in[1])) {
			struct link *link = browser->links;
			int linksel = (int)strtol(in+1, &endptr, 10);
			while (linksel > 0 && link) {
				link = link->next;
				--linksel;
			}

			if (!link) {
				fprintf(stderr, "Error: no such link.\n");
				goto exit;
			} else {
				d_url = link->url;
			}
		}
		struct gemini_response resp;
		strncpy(&save_url[0], browser->plain_url, sizeof(url)-1);
		strncpy(&curr_url[0], d_url, sizeof(url)-1);
		// XXX: may affect history, do we care?
		set_url(browser, curr_url, NULL);
		enum gemini_result res = do_requests(browser, &resp);
		if (res != GEMINI_OK) {
			fprintf(stderr, "Error: %s\n",
				gemini_strerr(res, &resp));
			set_url(browser, save_url, NULL);
			goto exit;
		}
		download_resp(browser->tty, resp, trim_ws(endptr), curr_url);
		gemini_response_finish(&resp);
		set_url(browser, save_url, NULL);
		goto exit;
	case '|':
		strncpy(&curr_url[0], browser->plain_url, sizeof(url)-1);
		res = do_requests(browser, &resp);
		if (res != GEMINI_OK) {
			fprintf(stderr, "Error: %s\n",
				gemini_strerr(res, &resp));
			goto exit;
		}
		pipe_resp(browser->tty, resp, &in[1]);
		gemini_response_finish(&resp);
		set_url(browser, curr_url, NULL);
		goto exit;
	case '?':
		if (in[1]) {
			fprintf(stderr, "Error: unrecognized command.\n");
			goto exit;
		}
		fprintf(browser->tty, "%s", help_msg);
		goto exit;
	default:
		if (isdigit(in[0])) break;
		fprintf(stderr, "Error: unrecognized command.\n");
		goto exit;
	}

	if (isdigit(in[0])) {
		struct link *link = browser->links;
		int linksel = (int)strtol(in, &endptr, 10);
		if ((endptr[0] && endptr[0] != '|') || linksel < 0) {
			fprintf(stderr, "Error: no such link.\n");
			goto exit;
		}

		while (linksel > 0 && link) {
			link = link->next;
			--linksel;
		}

		if (!link) {
			fprintf(stderr, "Error: no such link.\n");
			goto exit;
		} else if (endptr[0] == '|') {
			struct gemini_response resp;
			strncpy(curr_url, browser->plain_url, sizeof(curr_url) - 1);
			set_url(browser, link->url, &browser->history);
			enum gemini_result res = do_requests(browser, &resp);
			if (res != GEMINI_OK) {
				fprintf(stderr, "Error: %s\n",
					gemini_strerr(res, &resp));
				set_url(browser, curr_url, NULL);
				goto exit;
			}
			pipe_resp(browser->tty, resp, &endptr[1]);
			gemini_response_finish(&resp);
			set_url(browser, curr_url, NULL);
			goto exit;
		} else {
			assert(endptr[0] == '\0');
			set_url(browser, link->url, &browser->history);
			result = PROMPT_ANSWERED;
			goto exit;
		}
	}

	set_url(browser, in, &browser->history);
	result = PROMPT_ANSWERED;
exit:
	if (browser->searching) {
		browser->searching = false;
		regfree(&browser->regex);
	}
exit_re:
	free(in);
	return result;
}

static int
wrap(FILE *f, char *s, struct winsize *ws, int *row, int *col)
{
	if (!s[0]) {
		fprintf(f, "\n");
		return 0;
	}
	for (int i = 0; s[i]; ++i) {
		switch (s[i]) {
		case '\n':
			assert(0); // Not supposed to happen
		case '\t':
			*col = *col + (8 - *col % 8);
			break;
		case '\r':
			if (!s[i+1]) break;
			/* fallthrough */
		default:
			// skip unicode continuation bytes
			if ((s[i] & 0xc0) == 0x80) break;

			if (iscntrl(s[i])) s[i] = '.';
			*col += 1;
			break;
		}

		if (*col >= ws->ws_col - 4) {
			int j = i--;
			while (&s[i] != s && !isspace(s[i])) --i;
			if (&s[i] == s) i = j;
			char c = s[i];
			s[i] = 0;
			int n = fprintf(f, "%s\n", s) - (isspace(c) ? 0 : 1);
			s[i] = c;
			*row += 1;
			*col = 0;
			return n;
		}
	}
	return fprintf(f, "%s\n", s) - 1;
}

static int
resp_read(void *state, void *buf, size_t nbyte)
{
	struct gemini_response *resp = state;
	if (resp->sc) {
		return br_sslio_read(&resp->body, buf, nbyte);
	} else {
		return read(resp->fd, buf, nbyte);
	}
}

static bool
display_gemini(struct browser *browser, struct gemini_response *resp)
{
	int nlinks = 0;
	struct gemini_parser p;
	gemini_parser_init(&p, &resp_read, resp);
	free(browser->page_title);
	browser->page_title = NULL;

	struct winsize ws;
	ioctl(fileno(browser->tty), TIOCGWINSZ, &ws);
	if (browser->max_width != 0 && ws.ws_col > browser->max_width) {
		ws.ws_col = browser->max_width;
	}

	FILE *out = browser->tty;
	bool searching = browser->searching;
	if (searching) {
		out = fopen("/dev/null", "w+");
	}

	fprintf(out, "\n");
	char *text = NULL;
	int row = 0, col = 0;
	bool alttext_printed = false;
	struct gemini_token tok;
	struct link **next = &browser->links;
	// When your screen is too narrow, more lines will be used for helptext and URL.
	// 87 is the maximum width of the prompt.
	int info_rows = (ws.ws_col >= 87) ? 4 : 6;

	while (text != NULL || gemini_parser_next(&p, &tok) == 0) {
repeat:
		switch (tok.token) {
		case GEMINI_TEXT:
			col += fprintf(out, "     ");
			if (text == NULL) {
				text = tok.text;
			}
			break;
		case GEMINI_LINK:
			if (text == NULL) {
				col += fprintf(out, "%3d) %s", nlinks++, (!strncmp("gemini://", tok.link.url, 9)) ? ANSI_COLOR_CYAN : ((strstr(tok.link.url, "://") == NULL) ? ANSI_COLOR_LCYAN : ANSI_COLOR_LMAGENTA));
				text = trim_ws(tok.link.text ? tok.link.text : tok.link.url);
				*next = calloc(1, sizeof(struct link));
				(*next)->url = strdup(trim_ws(tok.link.url));
				next = &(*next)->next;
			} else {
				col += fprintf(out, "     ");
			}
			break;
		case GEMINI_PREFORMATTED_BEGIN:
			alttext_printed = false;
			if (text == NULL && browser->alttext && tok.preformatted != NULL) {
				fprintf(out, "   A %s", ANSI_COLOR_GRAY);
				text = trim_ws(tok.preformatted);
				alttext_printed = true;
			}
			break;
			/* fallthrough */
		case GEMINI_PREFORMATTED_END:
			continue; // Not used
		case GEMINI_PREFORMATTED_TEXT:
			if (alttext_printed) continue;
			if (text == NULL) {
				fprintf(out, "   P %s", ANSI_COLOR_GRAY);
				text = tok.preformatted;
			}
			break;
		case GEMINI_HEADING:
			if (!browser->page_title) {
				browser->page_title = strdup(tok.heading.title);
			}
			if (text == NULL) {
				switch (tok.heading.level) {
				case 1:
					col += fprintf(out, "   # %s", ANSI_COLOR_RED);
					break;
				case 2:
					col += fprintf(out, "  ## %s", ANSI_COLOR_YELLOW);
					break;
				case 3:
					col += fprintf(out, " ### %s", ANSI_COLOR_GREEN);
					break;
				}
				text = trim_ws(tok.heading.title);
			} else {
				col += fprintf(out, "     ");
			}
			break;
		case GEMINI_LIST_ITEM:
			if (text == NULL) {
				col += fprintf(out, "   %s ",
					browser->unicode ? "•" : "*");
				text = trim_ws(tok.list_item);
			} else {
				col += fprintf(out, "     ");
			}
			break;
		case GEMINI_QUOTE:
			col += fprintf(out, "   %s ", browser->unicode ? "┃" : ">");
			if (text == NULL) {
				text = trim_ws(tok.quote_text);
			}
			break;
		}

		if (text && searching) {
			int r = regexec(&browser->regex, text, 0, NULL, 0);
			if (r != 0) {
				text = NULL;
				continue;
			} else {
				fclose(out);
				row = col = 0;
				out = browser->tty;
				text = NULL;
				searching = false;
				goto repeat;
			}
		}

		if (text) {
			int w = wrap(out, text, &ws, &row, &col);
			text += w;
			if (text[0] && row < ws.ws_row - info_rows) {
				continue;
			}

			if (!text[0]) {
				text = NULL;
			}
		}
		if (text == NULL) {
			gemini_token_finish(&tok);
		}

		while (col >= ws.ws_col) {
			col -= ws.ws_col;
			++row;
		}
		++row; col = 0;

		fprintf(out, ANSI_COLOR_RESET);
		if (browser->pagination && row >= ws.ws_row - info_rows) {
			char prompt[4096];
			char *end = NULL;
			if (browser->meta && (end = strchr(resp->meta, ';')) != NULL) {
				*end = 0;
			}
			snprintf(prompt, sizeof(prompt), "%s at %s\n"
				"[Enter]: read more; %s[N]: follow Nth link; %s%s[q]uit; [?]; or type a URL\n"
				"(more) => ", resp->meta, browser->plain_url,
				browser->searching ? "[n]ext result; " : "",
				browser->history->prev ? "[b]ack; " : "",
				browser->history->next ? "[f]orward; " : "");
			if (end != NULL) {
				*end = ';';
			}
			enum prompt_result result = PROMPT_AGAIN;
			while (result == PROMPT_AGAIN) {
				result = do_prompts(prompt, browser);
			}

			switch (result) {
			case PROMPT_AGAIN:
			case PROMPT_MORE:
				printf("\n");
				break;
			case PROMPT_QUIT:
				browser->running = false;
				if (text != NULL) {
					gemini_token_finish(&tok);
				}
				gemini_parser_finish(&p);
				return true;
			case PROMPT_ANSWERED:
				if (text != NULL) {
					gemini_token_finish(&tok);
				}
				gemini_parser_finish(&p);
				return true;
			case PROMPT_NEXT:
				searching = true;
				out = fopen("/dev/null", "w");
				break;
			}

			row = col = 0;
		}
	}

	gemini_token_finish(&tok);
	gemini_parser_finish(&p);
	return false;
}

static bool
display_plaintext(struct browser *browser, struct gemini_response *resp)
{
	struct winsize ws;
	int row = 0, col = 0;
	ioctl(fileno(browser->tty), TIOCGWINSZ, &ws);

	char buf[BUFSIZ];
	for (int n = 1; n > 0;) {
		if (resp->sc) {
			n = br_sslio_read(&resp->body, buf, BUFSIZ);
		} else {
			n = read(resp->fd, buf, BUFSIZ);
		}
		if (n < 0) {
			n = 0;
		}
		for (int i = 0; i < n; i++) {
			if (iscntrl(buf[i]) && (buf[i] < '\t' || buf[i] > '\v')) {
				buf[i] = '.';
			}
		}
		ssize_t w = 0;
		while (w < (ssize_t)n) {
			ssize_t x = fwrite(&buf[w], 1, n - w, browser->tty);
			if (ferror(browser->tty)) {
				fprintf(stderr, "Error: write: %s\n",
					strerror(errno));
				return 1;
			}
			w += x;
		}
	}

	(void)row; (void)col; // TODO: generalize pagination
	return false;
}

static bool
display_response(struct browser *browser, struct gemini_response *resp)
{
	if (gemini_response_class(resp->status) != GEMINI_STATUS_CLASS_SUCCESS) {
		return false;
	}
	printf("%c]0;%s%s%c", '\033', browser->plain_url, " - cgmnlm", '\007');
	if (strcmp(resp->meta, "text/gemini") == 0
			|| strncmp(resp->meta, "text/gemini;", 12) == 0) {
		return display_gemini(browser, resp);
	}
	if (strncmp(resp->meta, "text/", 5) == 0) {
		return display_plaintext(browser, resp);
	}
	fprintf(stderr, "Media type %s is unsupported, use \"d [path]\" to download this page\n",
		resp->meta);
	return false;
}

static enum tofu_action
tofu_callback(enum tofu_error error, const char *fingerprint,
	struct known_host *khost, void *data)
{
	struct browser *browser = data;
	if (browser->tofu_mode != TOFU_ASK) {
		return browser->tofu_mode;
	}

	static char prompt[8192];
	switch (error) {
	case TOFU_VALID:
		assert(0); // Invariant
	case TOFU_INVALID_CERT:
		snprintf(prompt, sizeof(prompt),
			"The certificate offered by this server IS INVALID.\n"
			"/!\\ Someone may be eavesdropping on or manipulating this connection. /!\\\n"
			"If you choose to proceed, you should not disclose personal information or trust "
			"the contents of the page.\n"
			"[a]bort; trust [o]nce\n"
			"=> ");
		break;
	case TOFU_UNTRUSTED_CERT:;
		char *host;
		if (curl_url_get(browser->url, CURLUPART_HOST, &host, 0) != CURLUE_OK) {
			fprintf(stderr, "Error: invalid URL %s\n",
				browser->plain_url);
			return TOFU_FAIL;
		}
		snprintf(prompt, sizeof(prompt),
			"The certificate offered by %s is of unknown trust. "
			"Its fingerprint is: \n"
			"%s\n\n"
			"If you knew the fingerprint to expect in advance, verify that this matches.\n"
			"Otherwise, it should be safe to trust this certificate.\n\n"
			"[t]rust always; trust [o]nce; [a]bort\n"
			"=> ", host, fingerprint);
		free(host);
		break;
	case TOFU_FINGERPRINT_MISMATCH:
		snprintf(prompt, sizeof(prompt),
			"The certificate offered by this server DOES NOT MATCH the one we have on file.\n"
			"/!\\ Someone may be eavesdropping on or manipulating this connection. /!\\\n"
			"The unknown certificate's fingerprint is:\n"
			"%s\n\n"
			"The expected fingerprint is:\n"
			"%s\n\n"
			"If you choose to proceed, you should not disclose personal information or trust "
			"the contents of the page.\n"
			"[a]bort; trust [o]nce; [t]rust anyway\n"
			"=> ", fingerprint, khost->fingerprint);
		break;
	}

	bool prompting = true;
	while (prompting) {
		fprintf(browser->tty, "%s", prompt);

		size_t sz = 0;
		char *line = NULL;
		if (getline(&line, &sz, browser->tty) == -1) {
			free(line);
			return TOFU_FAIL;
		}
		if (line[1] != '\n') {
			free(line);
			continue;
		}

		char c = line[0];
		free(line);

		switch (c) {
		case 't':
			if (error == TOFU_INVALID_CERT) {
				break;
			}
			return TOFU_TRUST_ALWAYS;
		case 'o':
			return TOFU_TRUST_ONCE;
		case 'a':
			return TOFU_FAIL;
		}
	}

	return TOFU_FAIL;
}

int
main(int argc, char *argv[])
{
	struct browser browser = {
		.pagination = true,
		.alttext = false,
		.autoopen = false,
		.tofu_mode = TOFU_ASK,
		.unicode = true,
		.url = curl_url(),
		.tty = fopen("/dev/tty", "w+"),
		.meta = NULL,
		.max_redirs = REDIRS_ASK,
	};

	int c;
	while ((c = getopt(argc, argv, "hj:PR:UW:TA")) != -1) {
		switch (c) {
		case 'h':
			usage(argv[0]);
			return 0;
		case 'j':
			if (strcmp(optarg, "fail") == 0) {
				browser.tofu_mode = TOFU_FAIL;
			} else if (strcmp(optarg, "once") == 0) {
				browser.tofu_mode = TOFU_TRUST_ONCE;
			} else if (strcmp(optarg, "always") == 0) {
				browser.tofu_mode = TOFU_TRUST_ALWAYS;
			} else {
				usage(argv[0]);
				return 1;
			}
			break;
		case 'T':
			browser.autoopen = true;
			break;
		case 'A':
			browser.alttext = true;
			break;
		case 'P':
			browser.pagination = false;
			break;
		case 'R':;
			int mr = strtol(optarg, NULL, 10);
			browser.max_redirs = mr < 0 ? REDIRS_UNLIMITED : mr;
			break;
		case 'U':
			browser.unicode = false;
			break;
		case 'W':
			browser.max_width = strtoul(optarg, NULL, 10);
			break;
		default:
			fprintf(stderr, "fatal: unknown flag %c\n", c);
			curl_url_cleanup(browser.url);
			return 1;
		}
	}

	if (optind == argc - 1) {
		if (!set_url(&browser, argv[optind], &browser.history)) {
			return 1;
		}
	} else {
		open_bookmarks(&browser);
	}

	gemini_tofu_init(&browser.tofu, &tofu_callback, &browser);

	struct gemini_response resp;
	browser.running = true;
	while (browser.running) {
		static char prompt[4096];
		bool skip_prompt = do_requests(&browser, &resp) == GEMINI_OK
			&& display_response(&browser, &resp);
		if (browser.meta) {
			free(browser.meta);
		}
		browser.meta = resp.status == GEMINI_STATUS_SUCCESS
			? strdup(resp.meta) : NULL;
		gemini_response_finish(&resp);
		if (!skip_prompt) {
			char *end = NULL;
			if (browser.meta && (end = strchr(browser.meta, ';')) != NULL) {
				*end = 0;
			}
			snprintf(prompt, sizeof(prompt), "\n%s at %s\n"
				"[N]: follow Nth link; %s%s[q]uit; [?]; or type a URL\n"
				"=> ", browser.meta ? browser.meta
				: "[request failed]", browser.plain_url,
				browser.history->prev ? "[b]ack; " : "",
				browser.history->next ? "[f]orward; " : "");
			if (end != NULL) {
				*end = ';';
			}

			enum prompt_result result = PROMPT_AGAIN;
			while (result == PROMPT_AGAIN || result == PROMPT_MORE) {
				result = do_prompts(prompt, &browser);
			}
			switch (result) {
			case PROMPT_AGAIN:
			case PROMPT_MORE:
				assert(0);
			case PROMPT_QUIT:
				browser.running = false;
				break;
			case PROMPT_ANSWERED:
			case PROMPT_NEXT:
				break;
			}
		}

		struct link *link = browser.links;
		while (link) {
			struct link *next = link->next;
			free(link->url);
			free(link);
			link = next;
		}
		browser.links = NULL;
	}

	gemini_tofu_finish(&browser.tofu);
	struct history *hist = browser.history;
	while (hist && hist->prev) {
		hist = hist->prev;
	}
	history_free(hist);
	curl_url_cleanup(browser.url);
	free(browser.page_title);
	free(browser.plain_url);
	if (browser.meta != NULL) {
		free(browser.meta);
	}
	fclose(browser.tty);
	return 0;
}