💾 Archived View for gmn.clttr.info › sources › cgmnlm.git › tree › src › gmnlm.c.txt captured on 2022-06-03 at 23:26:07.
⬅️ Previous capture (2022-04-28)
-=-=-=-=-=-=-
#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; }; struct browser { bool pagination, unicode, alttext, autoopen; int max_width; 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 is optional)\n" "t[N]\t\tDownload content of current page or Nth link to a temporary file (N is optional)\n" "b[N]\t\tJump back N entries in history (N is optional, default 1)\n" "f[N]\t\tJump forward N entries in history (N is optional, default 1)\n" "u\t\tNavigate one path element up\n" "H\t\tView all page history\n" "m [title]\t\tSave bookmark with name, 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 URI 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] [-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, NULL); free(new_url); break; case GEMINI_STATUS_CLASS_REDIRECT: if (++nredir >= 5) { requesting = false; fprintf(stderr, "Error: maximum redirects (5) exceeded\n"); 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; fprintf(browser->tty, "%s", prompt); struct link *link = browser->links; char *endptr = NULL; int linksel = 0; int historyhops = 1; char *in = NULL; size_t l = 0; 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 url[1024] = {0}; char save_url[1024] = {0}; int r; switch (in[0]) { case '\0': result = PROMPT_MORE; goto exit; case 'q': if (in[1]) break; result = PROMPT_QUIT; 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(url, sizeof(url), "gemini://geminispace.info/backlinks?%s", browser->plain_url); set_url(browser, url, &browser->history); result = PROMPT_ANSWERED; goto exit; case 'b': if (in[1]) historyhops =(int)strtol(in+1, &endptr, 10); 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 'f': if (in[1]) historyhops =(int)strtol(in+1, &endptr, 10); 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(url , browser->plain_url, keep+1); set_url(browser, url, &browser->history); } result = PROMPT_ANSWERED; goto exit; case 'H': if (in[1]) break; 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; } result = PROMPT_AGAIN; goto exit; case 'm': if (in[1] != '\0' && !isspace(in[1])) break; char *title = in[1] ? &in[1] : browser->page_title; save_bookmark(browser, title ? trim_ws(title) : title); result = PROMPT_AGAIN; goto exit; case 'k': if (in[1]) break; remove_bookmark(browser); result = PROMPT_AGAIN; goto exit; case 'M': if (in[1]) break; open_bookmarks(browser); result = PROMPT_ANSWERED; 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); result = PROMPT_AGAIN; } else { browser->searching = true; result = PROMPT_ANSWERED; } goto exit_re; case 'n': if (in[1]) break; if (browser->searching) { result = PROMPT_NEXT; goto exit_re; } else { fprintf(stderr, "Cannot move to next result; we are not searching for anything\n"); result = PROMPT_AGAIN; goto exit; } case 'e': case 'p': case 't': strncpy(&save_url[0], browser->plain_url, sizeof(url)-1); if (!in[1]) { strncpy(&url[0], browser->plain_url, sizeof(url)-1); } else { 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(&url[0], link->url, sizeof(url)-1); } } } if (url[0]) { fprintf(browser->tty, "=> %s\n", url); char *tempfile; tempfile = tmpnam(NULL); if (in[0] == 't') { struct gemini_response resp; set_url(browser, 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, 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 : url); if ( !system(target) ) fprintf(browser->tty, "Link send to xdg-open\n"); } fprintf(browser->tty, "\n"); } result = PROMPT_AGAIN; goto exit; case 'r': if (in[1]) break; result = PROMPT_ANSWERED; goto exit; case 'i': if (in[1]) break; print_media_parameters(browser->tty, browser->meta ? strchr(browser->meta, ';') : NULL); result = PROMPT_AGAIN; 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"); break; } else { d_url = link->url; } } struct gemini_response resp; char url[1024] = {0}, old_url[1024] = {0}; strncpy(&old_url[0], browser->plain_url, sizeof(url)-1); strncpy(&url[0], d_url, sizeof(url)-1); // XXX: may affect history, do we care? set_url(browser, url, NULL); enum gemini_result res = do_requests(browser, &resp); if (res != GEMINI_OK) { fprintf(stderr, "Error: %s\n", gemini_strerr(res, &resp)); result = PROMPT_AGAIN; set_url(browser, old_url, NULL); goto exit; } download_resp(browser->tty, resp, trim_ws(endptr), url); gemini_response_finish(&resp); result = PROMPT_AGAIN; set_url(browser, old_url, NULL); goto exit; case '|': strncpy(&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)); result = PROMPT_AGAIN; goto exit; } pipe_resp(browser->tty, resp, &in[1]); gemini_response_finish(&resp); set_url(browser, url, NULL); result = PROMPT_AGAIN; goto exit; case '?': if (in[1]) break; fprintf(browser->tty, "%s", help_msg); result = PROMPT_AGAIN; goto exit; } linksel = (int)strtol(in, &endptr, 10); if ((endptr[0] == '\0' || endptr[0] == '|') && linksel >= 0) { while (linksel > 0 && link) { link = link->next; --linksel; } if (!link) { fprintf(stderr, "Error: no such link.\n"); } else if (endptr[0] == '|') { struct gemini_response resp; strncpy(url, browser->plain_url, sizeof(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, url, NULL); result = PROMPT_AGAIN; goto exit; } pipe_resp(browser->tty, resp, &endptr[1]); gemini_response_finish(&resp); set_url(browser, url, NULL); result = PROMPT_AGAIN; 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 server presented an invalid certificate. If you choose to proceed, " "you should not disclose personal information or trust the contents of the page.\n" "trust [o]nce; [a]bort\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: fprintf(browser->tty, "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're certain that this is correct, edit %s:%d\n", fingerprint, khost->fingerprint, browser->tofu.known_hosts_path, khost->lineno); return TOFU_FAIL; } 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, }; int c; while ((c = getopt(argc, argv, "hj:PTAUW:")) != -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 '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; }