gmnlm.c (33316B)
1 #include <assert.h> 2 #include <bearssl.h> 3 #include <ctype.h> 4 #include <errno.h> 5 #include <getopt.h> 6 #include <gmni/certs.h> 7 #include <gmni/gmni.h> 8 #include <gmni/tofu.h> 9 #include <gmni/url.h> 10 #include <libgen.h> 11 #include <limits.h> 12 #include <regex.h> 13 #include <stdbool.h> 14 #include <stdio.h> 15 #include <stdlib.h> 16 #include <string.h> 17 #include <sys/ioctl.h> 18 #include <sys/stat.h> 19 #include <sys/types.h> 20 #include <sys/wait.h> 21 #include <termios.h> 22 #include <unistd.h> 23 #include "util.h" 24 25 // not exported in gmni/gmni.h 26 int plaintext_parser_next(struct gemini_parser *, struct gemini_token *); 27 28 struct link { 29 char *url; 30 struct link *next; 31 }; 32 33 struct history { 34 char *url; 35 struct history *prev, *next; 36 }; 37 38 struct headings { 39 int level; 40 char *title; 41 struct headings *next; 42 }; 43 44 #define REDIRS_UNLIMITED -1 45 #define REDIRS_ASK -2 46 47 struct browser { 48 bool ansi, pagination, unicode; 49 int max_width; 50 int max_redirs; 51 struct gemini_options opts; 52 struct gemini_tofu tofu; 53 enum tofu_action tofu_mode; 54 55 FILE *tty; 56 char *meta; 57 char *plain_url; 58 char *page_title; 59 struct Curl_URL *url; 60 struct link *links; 61 struct history *history; 62 struct headings *headings; 63 bool running; 64 65 bool searching; 66 regex_t regex; 67 68 int target_heading; 69 }; 70 71 enum prompt_result { 72 PROMPT_AGAIN, 73 PROMPT_MORE, 74 PROMPT_QUIT, 75 PROMPT_ANSWERED, 76 PROMPT_NEXT, 77 PROMPT_RELOAD, 78 }; 79 80 const char *default_bookmarks = 81 "# Welcome to gmni\n\n" 82 "Links:\n\n" 83 // TODO: sub out the URL for the appropriate geminispace version once 84 // sr.ht supports gemini 85 "=> https://sr.ht/~sircmpwn/gmni The gmni browser\n" 86 "=> gemini://gemini.circumlunar.space The gemini protocol\n\n" 87 "This file can be found at %s and may be edited at your pleasure.\n\n" 88 "Bookmarks:\n" 89 ; 90 91 const char *help_msg = 92 "The following commands are available:\n\n" 93 "q\r\t\tQuit\n" 94 "<N>\r\t\tFollow Nth link\n" 95 "p<N>\r\t\tShow URL of Nth link\n" 96 "b[N]\r\t\tJump back N entries in history, default is 1\n" 97 "f[N]\r\t\tJump forward N entries in history, default is 1\n" 98 "H\r\t\tView all page history\n" 99 "t\r\t\tShow table of contents\n" 100 "#<N>\r\t\tJump to Nth heading\n" 101 "m [title]\r\t\tSave bookmark\n" 102 "M\r\t\tBrowse bookmarks\n" 103 "r\r\t\tReload the page\n" 104 "R\r\t\tRedisplay the page\n" 105 "i\r\t\tShow MIME type parameters\n" 106 "d[N] [path]\r\t\tDownload page, or Nth link, to path\n" 107 "[N]|<prog>\r\t\tPipe page, or Nth link, into program\n" 108 "/<regex>\r\t\tSearch for regular expression (POSIX ERE)\n" 109 ; 110 111 static void 112 usage(const char *argv_0) 113 { 114 fprintf(stderr, "usage: %s [-PU] [-j mode] [-R redirs] [-W width] [gemini://...]\n", argv_0); 115 } 116 117 static void 118 history_free(struct history *history) 119 { 120 if (!history) { 121 return; 122 } 123 history_free(history->next); 124 free(history->url); 125 free(history); 126 } 127 128 static bool 129 set_url(struct browser *browser, char *new_url, struct history **history) 130 { 131 if (curl_url_set(browser->url, CURLUPART_URL, new_url, 0) != CURLUE_OK) { 132 fprintf(stderr, "Error: invalid URL\n"); 133 return false; 134 } 135 if (browser->plain_url != NULL) { 136 free(browser->plain_url); 137 } 138 curl_url_get(browser->url, CURLUPART_URL, &browser->plain_url, 0); 139 if (history) { 140 struct history *next = calloc(1, sizeof(struct history)); 141 curl_url_get(browser->url, CURLUPART_URL, &next->url, 0); 142 next->prev = *history; 143 if (*history) { 144 if ((*history)->next) { 145 history_free((*history)->next); 146 } 147 (*history)->next = next; 148 } 149 *history = next; 150 } 151 return true; 152 } 153 154 static char * 155 get_data_pathfmt() 156 { 157 const struct pathspec paths[] = { 158 {.var = "GMNIDATA", .path = "/%s"}, 159 {.var = "XDG_DATA_HOME", .path = "/gmni/%s"}, 160 {.var = "HOME", .path = "/.local/share/gmni/%s"} 161 }; 162 return getpath(paths, sizeof(paths) / sizeof(paths[0])); 163 } 164 165 static void 166 save_bookmark(struct browser *browser, const char *title) 167 { 168 char *path_fmt = get_data_pathfmt(); 169 static char path[PATH_MAX+1]; 170 static char dname[PATH_MAX+1]; 171 size_t n; 172 173 n = snprintf(path, sizeof(path), path_fmt, "bookmarks.gmi"); 174 free(path_fmt); 175 assert(n < sizeof(path)); 176 posix_dirname(path, dname); 177 if (mkdirs(dname, 0755) != 0) { 178 fprintf(stderr, "Error creating directory %s: %s\n", 179 dname, strerror(errno)); 180 return; 181 } 182 183 FILE *f = fopen(path, "a"); 184 if (!f) { 185 fprintf(stderr, "Error opening %s for writing: %s\n", 186 path, strerror(errno)); 187 return; 188 } 189 190 fprintf(f, "=> %s%s%s\n", browser->plain_url, 191 title ? " " : "", title ? title : ""); 192 fclose(f); 193 194 fprintf(browser->tty, "Bookmark saved: %s\n", 195 title ? title : browser->plain_url); 196 } 197 198 static void 199 open_bookmarks(struct browser *browser) 200 { 201 char *path_fmt = get_data_pathfmt(); 202 static char path[PATH_MAX+1]; 203 static char dname[PATH_MAX+1]; 204 size_t n; 205 206 n = snprintf(path, sizeof(path), path_fmt, "bookmarks.gmi"); 207 free(path_fmt); 208 assert(n < sizeof(path)); 209 posix_dirname(path, dname); 210 if (mkdirs(dname, 0755) != 0) { 211 fprintf(stderr, "Error creating directory %s: %s\n", 212 dname, strerror(errno)); 213 return; 214 } 215 216 struct stat buf; 217 if (stat(path, &buf) == -1 && errno == ENOENT) { 218 // TOCTOU, but we almost certainly don't care 219 FILE *f = fopen(path, "a"); 220 if (f == NULL) { 221 fprintf(stderr, "Error opening %s for writing: %s\n", 222 path, strerror(errno)); 223 return; 224 } 225 fprintf(f, default_bookmarks, path); 226 fclose(f); 227 } 228 229 static char url[PATH_MAX+1+7]; 230 snprintf(url, sizeof(url), "file://%s", path); 231 set_url(browser, url, &browser->history); 232 } 233 234 static void 235 print_media_parameters(FILE *out, char *params) 236 { 237 if (params == NULL) { 238 fprintf(out, "No media parameters\n"); 239 return; 240 } 241 for (char *param = strtok(params, ";"); param; 242 param = strtok(NULL, ";")) { 243 char *value = strchr(param, '='); 244 if (value == NULL) { 245 fprintf(out, "Invalid media type parameter '%s'\n", 246 trim_ws(param)); 247 continue; 248 } 249 *value = 0; 250 fprintf(out, "%s: ", trim_ws(param)); 251 *value++ = '='; 252 if (*value != '"') { 253 fprintf(out, "%s\n", value); 254 continue; 255 } 256 while (value++) { 257 switch (*value) { 258 case '\0': 259 if ((value = strtok(NULL, ";")) != NULL) { 260 fprintf(out, ";%c", *value); 261 } 262 break; 263 case '"': 264 value = NULL; 265 break; 266 case '\\': 267 if (value[1] == '\0') { 268 break; 269 } 270 value++; 271 /* fallthrough */ 272 default: 273 putc(*value, out); 274 } 275 } 276 putc('\n', out); 277 } 278 } 279 280 static char * 281 get_input(const struct gemini_response *resp, FILE *source) 282 { 283 int r = 0; 284 struct termios attrs; 285 bool tty = fileno(source) != -1 && isatty(fileno(source)); 286 char *input = NULL; 287 if (tty) { 288 fprintf(stderr, "%s: ", resp->meta); 289 if (resp->status == GEMINI_STATUS_SENSITIVE_INPUT) { 290 r = tcgetattr(fileno(source), &attrs); 291 struct termios new_attrs; 292 r = tcgetattr(fileno(source), &new_attrs); 293 if (r != -1) { 294 new_attrs.c_lflag &= ~ECHO; 295 tcsetattr(fileno(source), TCSANOW, &new_attrs); 296 } 297 } 298 } 299 size_t s = 0; 300 ssize_t n = getline(&input, &s, source); 301 if (n == -1) { 302 fprintf(stderr, "Error reading input: %s\n", 303 feof(source) ? "EOF" : strerror(ferror(source))); 304 return NULL; 305 } 306 input[n - 1] = '\0'; // Drop LF 307 if (tty && resp->status == GEMINI_STATUS_SENSITIVE_INPUT && r != -1) { 308 attrs.c_lflag &= ~ECHO; 309 tcsetattr(fileno(source), TCSANOW, &attrs); 310 } 311 return input; 312 } 313 314 static void 315 pipe_resp(FILE *out, struct gemini_response resp, char *cmd) { 316 int pfd[2]; 317 if (pipe(pfd) == -1) { 318 perror("pipe"); 319 return; 320 } 321 pid_t pid; 322 switch ((pid = fork())) { 323 case -1: 324 perror("fork"); 325 return; 326 case 0: 327 close(pfd[1]); 328 dup2(pfd[0], STDIN_FILENO); 329 close(pfd[0]); 330 execlp("sh", "sh", "-c", cmd, NULL); 331 perror("exec"); 332 _exit(1); 333 } 334 close(pfd[0]); 335 FILE *f = fdopen(pfd[1], "w"); 336 print_resp_file(false, resp, f); 337 fclose(f); 338 int status; 339 waitpid(pid, &status, 0); 340 if (status != 0) { 341 fprintf(out, "Command exited %d\n", status); 342 } 343 } 344 345 static enum gemini_result 346 do_requests(struct browser *browser, struct gemini_response *resp) 347 { 348 int nredir = 0; 349 bool requesting = true; 350 enum gemini_result res; 351 352 char *scheme; 353 CURLUcode uc = curl_url_get(browser->url, 354 CURLUPART_SCHEME, &scheme, 0); 355 assert(uc == CURLUE_OK); // Invariant 356 357 char *host = NULL; 358 struct gmni_client_certificate client_cert = {0}; 359 const struct pathspec paths[] = { 360 {.var = "GMNIDATA", .path = "/certs/%s.%s"}, 361 {.var = "XDG_DATA_HOME", .path = "/gmni/certs/%s.%s"}, 362 {.var = "HOME", .path = "/.local/share/gmni/certs/%s.%s"} 363 }; 364 char *path_fmt = getpath(paths, sizeof(paths) / sizeof(paths[0])); 365 char certpath[PATH_MAX+1], keypath[PATH_MAX+1]; 366 size_t n = 0; 367 368 if (strcmp(scheme, "gemini") == 0) { 369 CURLUcode uc = curl_url_get(browser->url, 370 CURLUPART_HOST, &host, 0); 371 assert(uc == CURLUE_OK); 372 373 n = snprintf(certpath, sizeof(certpath), path_fmt, host, "crt"); 374 assert(n < sizeof(certpath)); 375 FILE *certin = fopen(certpath, "r"); 376 if (certin) { 377 n = snprintf(keypath, sizeof(keypath), path_fmt, host, "key"); 378 assert(n < sizeof(keypath)); 379 380 FILE *skin = fopen(keypath, "r"); 381 if (gmni_ccert_load(&client_cert, certin, skin)) { 382 browser->opts.client_cert = NULL; 383 fprintf(stderr, "Unable to load client certificate for host %s", host); 384 } else { 385 browser->opts.client_cert = &client_cert; 386 } 387 } else { 388 browser->opts.client_cert = NULL; 389 } 390 } 391 392 while (requesting) { 393 res = gemini_request(browser->plain_url, &browser->opts, 394 &browser->tofu, resp); 395 if (res != GEMINI_OK) { 396 fprintf(stderr, "Error: %s\n", gemini_strerr(res, resp)); 397 requesting = false; 398 resp->status = 70 + res; 399 break; 400 } 401 402 char *input; 403 switch (gemini_response_class(resp->status)) { 404 case GEMINI_STATUS_CLASS_INPUT: 405 input = get_input(resp, browser->tty); 406 if (!input) { 407 requesting = false; 408 break; 409 } 410 if (input[0] == '\0' && browser->history->prev) { 411 free(input); 412 browser->history = browser->history->prev; 413 set_url(browser, browser->history->url, NULL); 414 break; 415 } 416 417 char *new_url = gemini_input_url( 418 browser->plain_url, input); 419 free(input); 420 assert(new_url); 421 set_url(browser, new_url, 422 resp->status == GEMINI_STATUS_SENSITIVE_INPUT ? 423 NULL : &browser->history); 424 free(new_url); 425 break; 426 case GEMINI_STATUS_CLASS_REDIRECT: 427 if (browser->max_redirs == REDIRS_ASK) { 428 again: 429 fprintf(browser->tty, 430 "The host %s is redirecting to:\n" 431 "%s\n\n" 432 "[F]ollow redirect; [a]bort\n" 433 "=> ", host, resp->meta); 434 435 size_t sz = 0; 436 char *line = NULL; 437 if (getline(&line, &sz, browser->tty) == -1) { 438 free(line); 439 requesting = false; 440 break; 441 } 442 if (line[0] != '\n' && line[1] != '\n') { 443 free(line); 444 goto again; 445 } 446 447 char c = line[0]; 448 free(line); 449 450 if (c == 'a') { 451 requesting = false; 452 break; 453 } else if (c != 'f' && c != '\n') { 454 goto again; 455 } 456 } else if (browser->max_redirs != REDIRS_UNLIMITED 457 && ++nredir >= browser->max_redirs) { 458 requesting = false; 459 fprintf(stderr, "Error: maximum redirects (%d) exceeded\n", 460 browser->max_redirs); 461 break; 462 } 463 set_url(browser, resp->meta, NULL); 464 break; 465 case GEMINI_STATUS_CLASS_CLIENT_CERTIFICATE_REQUIRED: 466 requesting = false; 467 assert(host); 468 n = snprintf(certpath, sizeof(certpath), path_fmt, host, "crt"); 469 assert(n < sizeof(certpath)); 470 n = snprintf(keypath, sizeof(keypath), path_fmt, host, "key"); 471 char dname[PATH_MAX + 1]; 472 posix_dirname(certpath, dname); 473 if (mkdirs(dname, 0755) != 0) { 474 fprintf(stderr, "Error creating directory %s: %s\n", 475 dname, strerror(errno)); 476 break; 477 } 478 assert(n < sizeof(keypath)); 479 fprintf(stderr, "The server requested a client certificate.\n" 480 "Presently, this process is not automated.\n" 481 "The following OpenSSL command will generate a certificate for this host:\n\n" 482 "openssl req -x509 -newkey rsa:4096 \\\n\t-keyout %s \\\n\t-out %s \\\n\t-days 36500 -nodes\n\n" 483 "Use the 'r' command to reload the page after creating this certificate.\n", 484 keypath, certpath); 485 break; 486 case GEMINI_STATUS_CLASS_TEMPORARY_FAILURE: 487 case GEMINI_STATUS_CLASS_PERMANENT_FAILURE: 488 requesting = false; 489 fprintf(stderr, "Server returned %s %d %s\n", 490 resp->status / 10 == 4 ? 491 "TEMPORARY FAILURE" : "PERMANENT FAILURE", 492 resp->status, resp->meta); 493 break; 494 case GEMINI_STATUS_CLASS_SUCCESS: 495 goto out; 496 } 497 498 if (requesting) { 499 gemini_response_finish(resp); 500 } 501 } 502 503 out: 504 if (client_cert.key) { 505 free(client_cert.key); 506 } 507 free(path_fmt); 508 free(scheme); 509 free(host); 510 return res; 511 } 512 513 static void 514 print_toc(FILE *out, struct headings *first) 515 { 516 int nheads = 0; 517 fprintf(out, "Table of contents:\n\n"); 518 for (struct headings *p = first; p; p = p->next) { 519 fprintf(out, "%*d. %s\n", (p->level) * 4 - 1, ++nheads, p->title); 520 } 521 } 522 523 static enum prompt_result 524 do_prompts(const char *prompt, struct browser *browser) 525 { 526 enum prompt_result result = PROMPT_AGAIN; 527 fprintf(browser->tty, "%s", prompt); 528 529 size_t l = 0; 530 char *in = NULL; 531 ssize_t n = getline(&in, &l, browser->tty); 532 if (n == -1 && feof(browser->tty)) { 533 fputc('\n', browser->tty); 534 result = PROMPT_QUIT; 535 goto exit; 536 } 537 in[n - 1] = 0; // Remove LF 538 char *endptr; 539 540 CURLU *url = curl_url(); 541 bool isurl = curl_url_set(url, CURLUPART_URL, in, 0) == CURLUE_OK; 542 curl_url_cleanup(url); 543 if (isurl) { 544 set_url(browser, in, &browser->history); 545 result = PROMPT_ANSWERED; 546 goto exit; 547 } 548 549 int historyhops = 1; 550 int r; 551 switch (in[0]) { 552 case '\0': 553 result = PROMPT_MORE; 554 goto exit; 555 case '.': 556 break; 557 case 'q': 558 if (in[1]) { 559 fprintf(stderr, "Error: unrecognized command.\n"); 560 goto exit; 561 } 562 result = PROMPT_QUIT; 563 goto exit; 564 case 'b': 565 if (in[1] && isdigit(in[1])) { 566 historyhops =(int)strtol(in+1, &endptr, 10); 567 if (endptr[0]) { 568 fprintf(stderr, "Error: invalid argument.\n"); 569 goto exit; 570 } 571 } else if (in[1]) { 572 fprintf(stderr, "Error: invalid argument.\n"); 573 goto exit; 574 } 575 while (historyhops > 0) { 576 if (browser->history->prev) { 577 browser->history = browser->history->prev; 578 } 579 historyhops--; 580 } 581 set_url(browser, browser->history->url, NULL); 582 result = PROMPT_ANSWERED; 583 goto exit; 584 case 'f': 585 if (in[1] && isdigit(in[1])) { 586 historyhops =(int)strtol(in+1, &endptr, 10); 587 if (endptr[0]) { 588 fprintf(stderr, "Error: invalid argument.\n"); 589 goto exit; 590 } 591 } else if (in[1]) { 592 fprintf(stderr, "Error: invalid argument.\n"); 593 goto exit; 594 } 595 while (historyhops > 0) { 596 if (browser->history->next) { 597 browser->history = browser->history->next; 598 } 599 historyhops--; 600 } 601 set_url(browser, browser->history->url, NULL); 602 result = PROMPT_ANSWERED; 603 goto exit; 604 case 'H': 605 if (in[1]) { 606 fprintf(stderr, "Error: unrecognized command.\n"); 607 goto exit; 608 } 609 struct history *cur = browser->history; 610 int hist_count = 0; 611 while (cur->prev) { 612 cur = cur->prev; 613 hist_count++; 614 } 615 while (cur != browser->history) { 616 fprintf(browser->tty, "b%-3i %s\n", hist_count--, cur->url); 617 cur = cur->next; 618 } 619 fprintf(browser->tty, "* %s\n", cur->url); 620 cur = cur->next; 621 while (cur) { 622 fprintf(browser->tty, "f%-3i %s\n", ++hist_count, cur->url); 623 cur = cur->next; 624 } 625 goto exit; 626 case 'm': 627 if (in[1] != '\0' && !isspace(in[1])) { 628 fprintf(stderr, "Error: unrecognized command.\n"); 629 goto exit; 630 } 631 char *title = in[1] ? &in[1] : browser->page_title; 632 save_bookmark(browser, title); 633 goto exit; 634 case 'M': 635 if (in[1]) { 636 fprintf(stderr, "Error: unrecognized command.\n"); 637 goto exit; 638 } 639 open_bookmarks(browser); 640 result = PROMPT_ANSWERED; 641 goto exit; 642 case '/': 643 if (!in[1]) break; 644 if ((r = regcomp(&browser->regex, &in[1], REG_EXTENDED)) != 0) { 645 static char buf[1024]; 646 r = regerror(r, &browser->regex, buf, sizeof(buf)); 647 assert(r < (int)sizeof(buf)); 648 fprintf(stderr, "Error: %s\n", buf); 649 } else { 650 browser->searching = true; 651 result = PROMPT_RELOAD; 652 } 653 goto exit_re; 654 case 'n': 655 if (in[1]) { 656 fprintf(stderr, "Error: unrecognized command.\n"); 657 goto exit; 658 } 659 if (browser->searching) { 660 result = PROMPT_NEXT; 661 goto exit_re; 662 } else { 663 fprintf(stderr, "Cannot move to next result; we are not searching for anything\n"); 664 goto exit; 665 } 666 case 'p': 667 if (!in[1]) { 668 fprintf(stderr, "Error: missing argument.\n"); 669 goto exit; 670 } else if (!isdigit(in[1])) { 671 fprintf(stderr, "Error: invalid argument.\n"); 672 goto exit; 673 } 674 struct link *link = browser->links; 675 int linksel = (int)strtol(in+1, &endptr, 10); 676 if (!endptr[0] && linksel >= 0) { 677 while (linksel > 0 && link) { 678 link = link->next; 679 --linksel; 680 } 681 682 if (!link) { 683 fprintf(stderr, "Error: no such link.\n"); 684 } else { 685 fprintf(browser->tty, "=> %s\n", link->url); 686 goto exit; 687 } 688 } else { 689 fprintf(stderr, "Error: invalid argument.\n"); 690 } 691 goto exit; 692 case 'r': 693 if (in[1]) { 694 fprintf(stderr, "Error: unrecognized command.\n"); 695 goto exit; 696 } 697 result = PROMPT_ANSWERED; 698 goto exit; 699 case 'i': 700 if (in[1]) { 701 fprintf(stderr, "Error: unrecognized command.\n"); 702 goto exit; 703 } 704 print_media_parameters(browser->tty, browser->meta 705 ? strchr(browser->meta, ';') : NULL); 706 goto exit; 707 case 't': 708 if (in[1]) { 709 fprintf(stderr, "Error: unrecognized command.\n"); 710 goto exit; 711 } 712 print_toc(browser->tty, browser->headings); 713 goto exit; 714 case 'R': 715 result = PROMPT_RELOAD; 716 goto exit; 717 case '#': 718 if (!in[1]) { 719 fprintf(stderr, "Error: missing argument.\n"); 720 goto exit; 721 } else if (!isdigit(in[1])) { 722 fprintf(stderr, "Error: invalid argument.\n"); 723 goto exit; 724 } 725 endptr = &in[1]; 726 int id = (int)strtol(endptr, &endptr, 10); 727 if (id <= 0 || *endptr) { 728 fprintf(stderr, "Error: invalid argument.\n"); 729 goto exit; 730 } 731 browser->target_heading = id; 732 result = PROMPT_RELOAD; 733 goto exit; 734 case 'd': 735 endptr = &in[1]; 736 char *d_url = browser->plain_url; 737 if (in[1] != '\0' && !isspace(in[1])) { 738 struct link *link = browser->links; 739 int linksel = (int)strtol(in+1, &endptr, 10); 740 while (linksel > 0 && link) { 741 link = link->next; 742 --linksel; 743 } 744 745 if (!link) { 746 fprintf(stderr, "Error: no such link.\n"); 747 goto exit; 748 } else { 749 d_url = link->url; 750 } 751 } 752 struct gemini_response resp; 753 char url[1024] = {0}, old_url[1024] = {0}; 754 strncpy(&old_url[0], browser->plain_url, sizeof(url)-1); 755 strncpy(&url[0], d_url, sizeof(url)-1); 756 // XXX: may affect history, do we care? 757 set_url(browser, url, NULL); 758 enum gemini_result res = do_requests(browser, &resp); 759 if (res != GEMINI_OK) { 760 fprintf(stderr, "Error: %s\n", 761 gemini_strerr(res, &resp)); 762 set_url(browser, old_url, NULL); 763 goto exit; 764 } 765 print_resp(false, resp, trim_ws(endptr), url); 766 gemini_response_finish(&resp); 767 set_url(browser, old_url, NULL); 768 goto exit; 769 case '|': 770 strncpy(&url[0], browser->plain_url, sizeof(url)-1); 771 res = do_requests(browser, &resp); 772 if (res != GEMINI_OK) { 773 fprintf(stderr, "Error: %s\n", 774 gemini_strerr(res, &resp)); 775 goto exit; 776 } 777 pipe_resp(browser->tty, resp, &in[1]); 778 gemini_response_finish(&resp); 779 set_url(browser, url, NULL); 780 goto exit; 781 case '?': 782 if (in[1]) { 783 fprintf(stderr, "Error: unrecognized command.\n"); 784 goto exit; 785 } 786 fprintf(browser->tty, "%s", help_msg); 787 goto exit; 788 default: 789 if (isdigit(in[0])) break; 790 fprintf(stderr, "Error: unrecognized command.\n"); 791 goto exit; 792 } 793 794 if (isdigit(in[0])) { 795 struct link *link = browser->links; 796 int linksel = (int)strtol(in, &endptr, 10); 797 if ((endptr[0] && endptr[0] != '|') || linksel < 0) { 798 fprintf(stderr, "Error: no such link.\n"); 799 goto exit; 800 } 801 802 while (linksel > 0 && link) { 803 link = link->next; 804 --linksel; 805 } 806 807 if (!link) { 808 fprintf(stderr, "Error: no such link.\n"); 809 goto exit; 810 } else if (endptr[0] == '|') { 811 char url[1024] = {0}; 812 struct gemini_response resp; 813 strncpy(url, browser->plain_url, sizeof(url) - 1); 814 set_url(browser, link->url, &browser->history); 815 enum gemini_result res = do_requests(browser, &resp); 816 if (res != GEMINI_OK) { 817 fprintf(stderr, "Error: %s\n", 818 gemini_strerr(res, &resp)); 819 set_url(browser, url, NULL); 820 goto exit; 821 } 822 pipe_resp(browser->tty, resp, &endptr[1]); 823 gemini_response_finish(&resp); 824 set_url(browser, url, NULL); 825 goto exit; 826 } else { 827 assert(endptr[0] == '\0'); 828 set_url(browser, link->url, &browser->history); 829 result = PROMPT_ANSWERED; 830 goto exit; 831 } 832 } 833 834 set_url(browser, in, &browser->history); 835 result = PROMPT_ANSWERED; 836 exit: 837 if (browser->searching) { 838 browser->searching = false; 839 regfree(&browser->regex); 840 } 841 exit_re: 842 free(in); 843 return result; 844 } 845 846 static int 847 wrap(FILE *f, char *s, struct winsize *ws, int *row, int *col) 848 { 849 if (!s[0]) { 850 fprintf(f, "\n"); 851 return 0; 852 } 853 for (int i = 0; s[i]; ++i) { 854 switch (s[i]) { 855 case '\n': 856 assert(0); // Not supposed to happen 857 case '\t': 858 *col = *col + (8 - *col % 8); 859 break; 860 case '\r': 861 if (!s[i+1]) break; 862 /* fallthrough */ 863 default: 864 // skip unicode continuation bytes 865 if ((s[i] & 0xc0) == 0x80) break; 866 867 if (iscntrl(s[i])) { 868 s[i] = '.'; 869 } 870 *col += 1; 871 break; 872 } 873 874 if (*col >= ws->ws_col) { 875 int j = i--; 876 while (&s[i] != s && !isspace(s[i])) --i; 877 if (&s[i] == s) { 878 i = j; 879 } 880 char c = s[i]; 881 s[i] = 0; 882 int n = fprintf(f, "%s\n", s) - (isspace(c) ? 0 : 1); 883 s[i] = c; 884 *row += 1; 885 *col = 0; 886 return n; 887 } 888 } 889 return fprintf(f, "%s\n", s) - 1; 890 } 891 892 static void 893 headings_free(struct browser *browser) 894 { 895 struct headings *head = browser->headings; 896 while (head) { 897 struct headings *next = head->next; 898 free(head->title); 899 free(head); 900 head = next; 901 } 902 browser->headings = NULL; 903 } 904 905 static void 906 links_free(struct browser *browser) 907 { 908 struct link *link = browser->links; 909 while (link) { 910 struct link *next = link->next; 911 free(link->url); 912 free(link); 913 link = next; 914 } 915 browser->links = NULL; 916 } 917 918 static bool 919 display_document(struct browser *browser, struct gemini_response *resp, 920 int (*parser_next)(struct gemini_parser *, struct gemini_token *)) 921 { 922 int nlinks = 0, nheadings = 0; 923 struct gemini_parser p; 924 gemini_parser_init(&p, resp->body); 925 free(browser->page_title); 926 browser->page_title = NULL; 927 links_free(browser); 928 headings_free(browser); 929 930 struct gemini_token tok; 931 struct link **nextlnk = &browser->links; 932 struct headings **nexthd = &browser->headings; 933 FILE *out = fopen("/dev/null", "w"); 934 while (parser_next(&p, &tok) == 0) { 935 if (tok.token == GEMINI_LINK) { 936 *nextlnk = calloc(1, sizeof(struct link)); 937 (*nextlnk)->url = strdup(tok.link.url); 938 nextlnk = &(*nextlnk)->next; 939 } else if(tok.token == GEMINI_HEADING) { 940 *nexthd = calloc(1, sizeof(struct headings)); 941 (*nexthd)->level = tok.heading.level; 942 (*nexthd)->title = strdup(tok.heading.title); 943 nexthd = &(*nexthd)->next; 944 } 945 gemini_token_finish(&tok); 946 } 947 fclose(out); 948 out = browser->tty; 949 gemini_parser_init(&p, resp->body); 950 951 char *text = NULL; 952 int row = 0, col = 0; 953 bool searching = browser->searching; 954 if (searching || browser->target_heading) { 955 out = fopen("/dev/null", "w"); 956 } 957 958 char prompt[4096]; 959 int info_rows = 0; 960 struct winsize ws; 961 bool first_screen = 1; 962 while (text != NULL || parser_next(&p, &tok) == 0) { 963 repeat: 964 if (!row) { 965 ioctl(fileno(browser->tty), TIOCGWINSZ, &ws); 966 if (browser->max_width != 0 && ws.ws_col > browser->max_width) { 967 ws.ws_col = browser->max_width; 968 } 969 970 char *end = NULL; 971 if (browser->meta && (end = strchr(resp->meta, ';')) != NULL) { 972 *end = 0; 973 } 974 snprintf(prompt, sizeof(prompt), "\n%s at %s\n" 975 "[Enter]: read more; %s[N]: follow Nth link; %s%s[q]uit; [?]; or type a URL\n" 976 "(more) => ", resp->meta, browser->plain_url, 977 browser->searching ? "[n]ext result; " : "", 978 browser->history->prev ? "[b]ack; " : "", 979 browser->history->next ? "[f]orward; " : ""); 980 if (end != NULL) { 981 *end = ';'; 982 } 983 984 info_rows = 0; 985 for (char *ln = prompt; (end = strchr(ln, '\n')); ln = end + 1) { 986 *end = '\0'; 987 int len = strlen(ln); 988 info_rows += len / ws.ws_col + (len % ws.ws_col != 0); 989 if (!*ln) info_rows++; // empty line 990 *end = '\n'; 991 } 992 // if not first screen, text is preceded by an empty line, 993 // and help text is followed by a prompt line 994 info_rows += !first_screen + 1; 995 } 996 997 if (browser->ansi && tok.token == GEMINI_HEADING) { 998 fprintf(out, "\x1b[1m"); 999 } 1000 switch (tok.token) { 1001 case GEMINI_TEXT: 1002 col += fprintf(out, " "); 1003 if (text == NULL) { 1004 text = tok.text; 1005 } 1006 break; 1007 case GEMINI_LINK: 1008 if (text == NULL) { 1009 col += fprintf(out, "%d) ", nlinks++); 1010 text = tok.link.text ? tok.link.text : tok.link.url; 1011 } else { 1012 col += fprintf(out, " "); 1013 } 1014 break; 1015 case GEMINI_PREFORMATTED_BEGIN: 1016 case GEMINI_PREFORMATTED_END: 1017 goto next; 1018 case GEMINI_PREFORMATTED_TEXT: 1019 if (text == NULL) { 1020 text = tok.preformatted; 1021 } 1022 break; 1023 case GEMINI_HEADING: 1024 if (browser->target_heading == nheadings + 1) { 1025 assert(out != browser->tty); 1026 fclose(out); 1027 row = col = 0; 1028 out = browser->tty; 1029 text = NULL; 1030 browser->target_heading = 0; 1031 goto repeat; 1032 } 1033 nheadings++; 1034 if (!browser->page_title) { 1035 browser->page_title = strdup(tok.heading.title); 1036 } 1037 if (text == NULL) { 1038 for (int n = tok.heading.level; n; --n) { 1039 col += fprintf(out, "#"); 1040 } 1041 for (int n = tok.heading.level; n < 3; ++n) { 1042 col += fprintf(out, " "); 1043 } 1044 text = tok.heading.title; 1045 } else { 1046 col += fprintf(out, " "); 1047 } 1048 break; 1049 case GEMINI_LIST_ITEM: 1050 if (text == NULL) { 1051 fprintf(out, " %s ", browser->unicode ? "•" : "*"); 1052 text = tok.list_item; 1053 col += 3; 1054 } else { 1055 col += fprintf(out, " "); 1056 } 1057 break; 1058 case GEMINI_QUOTE: 1059 fprintf(out, " %s ", browser->unicode ? "┃" : ">"); 1060 if (!text) text = tok.quote_text; 1061 col += 3; 1062 break; 1063 } 1064 1065 if (browser->target_heading) goto next; 1066 if (text && searching) { 1067 int r = regexec(&browser->regex, text, 0, NULL, 0); 1068 if (r != 0) { 1069 next: 1070 text = NULL; 1071 gemini_token_finish(&tok); 1072 continue; 1073 } else { 1074 assert(out != browser->tty); 1075 fclose(out); 1076 row = col = 0; 1077 out = browser->tty; 1078 text = NULL; 1079 searching = false; 1080 goto repeat; 1081 } 1082 } 1083 1084 if (text) { 1085 int w = wrap(out, text, &ws, &row, &col); 1086 text += w; 1087 if (text[0] && row < ws.ws_row - info_rows) { 1088 continue; 1089 } 1090 1091 if (!text[0]) { 1092 text = NULL; 1093 } 1094 } 1095 if (browser->ansi) fprintf(out, "\x1b[m"); 1096 if (text == NULL) { 1097 gemini_token_finish(&tok); 1098 } 1099 1100 while (col >= ws.ws_col) { 1101 col -= ws.ws_col; 1102 ++row; 1103 } 1104 ++row; col = 0; 1105 1106 if (browser->pagination && row >= ws.ws_row - info_rows) { 1107 first_screen = 0; 1108 enum prompt_result result = PROMPT_AGAIN; 1109 while (result == PROMPT_AGAIN) { 1110 result = do_prompts(prompt, browser); 1111 } 1112 1113 switch (result) { 1114 case PROMPT_AGAIN: 1115 case PROMPT_MORE: 1116 printf("\n"); 1117 break; 1118 case PROMPT_QUIT: 1119 browser->running = false; 1120 /* fall-through */ 1121 case PROMPT_ANSWERED: 1122 if (text != NULL) { 1123 gemini_token_finish(&tok); 1124 } 1125 return true; 1126 case PROMPT_RELOAD: 1127 if (text) gemini_token_finish(&tok); 1128 return display_document(browser, resp, parser_next); 1129 case PROMPT_NEXT: 1130 searching = true; 1131 out = fopen("/dev/null", "w"); 1132 break; 1133 } 1134 1135 row = col = 0; 1136 } 1137 } 1138 if (browser->target_heading) { 1139 fprintf(stderr, "Error: no such heading.\n"); 1140 browser->target_heading = 0; 1141 } 1142 1143 gemini_token_finish(&tok); 1144 return false; 1145 } 1146 1147 static bool 1148 display_response(struct browser *browser, struct gemini_response *resp) 1149 { 1150 if (gemini_response_class(resp->status) != GEMINI_STATUS_CLASS_SUCCESS) { 1151 return false; 1152 } 1153 if (strcmp(resp->meta, "text/gemini") == 0 1154 || strncmp(resp->meta, "text/gemini;", 12) == 0) { 1155 return display_document(browser, resp, &gemini_parser_next); 1156 } 1157 if (strncmp(resp->meta, "text/", 5) == 0) { 1158 return display_document(browser, resp, &plaintext_parser_next); 1159 } 1160 fprintf(stderr, "Media type %s is unsupported, use \"d [path]\" to download this page\n", 1161 resp->meta); 1162 return false; 1163 } 1164 1165 static enum tofu_action 1166 tofu_callback(enum tofu_error error, const char *fingerprint, 1167 struct known_host *khost, void *data) 1168 { 1169 struct browser *browser = data; 1170 if (browser->tofu_mode != TOFU_ASK) { 1171 return browser->tofu_mode; 1172 } 1173 1174 char *host; 1175 if (curl_url_get(browser->url, CURLUPART_HOST, &host, 0) != CURLUE_OK) { 1176 fprintf(stderr, "Error: invalid URL %s\n", 1177 browser->plain_url); 1178 return TOFU_FAIL; 1179 } 1180 static char prompt[8192]; 1181 switch (error) { 1182 case TOFU_VALID: 1183 assert(0); // Invariant 1184 case TOFU_INVALID_CERT: 1185 snprintf(prompt, sizeof(prompt), 1186 "The certificate offered by %s IS INVALID.\n" 1187 "/!\\ Someone may be eavesdropping on or manipulating this connection. /!\\\n" 1188 "If you choose to proceed, you should not disclose personal information or trust " 1189 "the contents of the page.\n" 1190 "[A]bort; trust [o]nce\n" 1191 "=> ", host); 1192 break; 1193 case TOFU_UNTRUSTED_CERT:; 1194 snprintf(prompt, sizeof(prompt), 1195 "The certificate offered by %s is of unknown trust. " 1196 "Its fingerprint is: \n" 1197 "%s\n\n" 1198 "If you knew the fingerprint to expect in advance, verify that this matches.\n" 1199 "Otherwise, it should be safe to trust this certificate.\n\n" 1200 "[T]rust always; trust [o]nce; [a]bort\n" 1201 "=> ", host, fingerprint); 1202 free(host); 1203 break; 1204 case TOFU_FINGERPRINT_MISMATCH:; 1205 snprintf(prompt, sizeof(prompt), 1206 "The certificate offered by %s DOES NOT MATCH the one we have on file.\n" 1207 "/!\\ Someone may be eavesdropping on or manipulating this connection. /!\\\n" 1208 "The unknown certificate's fingerprint is:\n" 1209 "%s\n\n" 1210 "The expected fingerprint is:\n" 1211 "%s\n\n" 1212 "If you choose to proceed, you should not disclose personal information or trust " 1213 "the contents of the page.\n" 1214 "[A]bort; trust [o]nce; [t]rust anyway\n" 1215 "=> ", host, fingerprint, khost->fingerprint); 1216 break; 1217 } 1218 1219 bool prompting = true; 1220 while (prompting) { 1221 fprintf(browser->tty, "%s", prompt); 1222 1223 size_t sz = 0; 1224 char *line = NULL; 1225 if (getline(&line, &sz, browser->tty) == -1) { 1226 free(line); 1227 return TOFU_FAIL; 1228 } 1229 1230 char c = line[0]; 1231 if (c == '\n') { 1232 return error == TOFU_UNTRUSTED_CERT ? 1233 TOFU_TRUST_ALWAYS : TOFU_FAIL; 1234 } else if (line[1] != '\n') { 1235 free(line); 1236 continue; 1237 } 1238 free(line); 1239 1240 switch (c) { 1241 case 't': 1242 if (error == TOFU_INVALID_CERT) { 1243 break; 1244 } 1245 return TOFU_TRUST_ALWAYS; 1246 case 'o': 1247 return TOFU_TRUST_ONCE; 1248 case 'a': 1249 return TOFU_FAIL; 1250 } 1251 } 1252 1253 return TOFU_FAIL; 1254 } 1255 1256 int 1257 main(int argc, char *argv[]) 1258 { 1259 struct browser browser = { 1260 .ansi = true, 1261 .pagination = true, 1262 .tofu_mode = TOFU_ASK, 1263 .unicode = true, 1264 .url = curl_url(), 1265 .tty = fopen("/dev/tty", "w+"), 1266 .meta = NULL, 1267 .max_redirs = REDIRS_ASK, 1268 }; 1269 1270 int c; 1271 while ((c = getopt(argc, argv, "Ahj:PR:UW:")) != -1) { 1272 switch (c) { 1273 case 'A': 1274 browser.ansi = false; 1275 break; 1276 case 'h': 1277 usage(argv[0]); 1278 return 0; 1279 case 'j': 1280 if (strcmp(optarg, "fail") == 0) { 1281 browser.tofu_mode = TOFU_FAIL; 1282 } else if (strcmp(optarg, "once") == 0) { 1283 browser.tofu_mode = TOFU_TRUST_ONCE; 1284 } else if (strcmp(optarg, "always") == 0) { 1285 browser.tofu_mode = TOFU_TRUST_ALWAYS; 1286 } else { 1287 usage(argv[0]); 1288 return 1; 1289 } 1290 break; 1291 case 'P': 1292 browser.pagination = false; 1293 break; 1294 case 'R':; 1295 int mr = strtol(optarg, NULL, 10); 1296 browser.max_redirs = mr < 0 ? REDIRS_UNLIMITED : mr; 1297 break; 1298 case 'U': 1299 browser.unicode = false; 1300 break; 1301 case 'W': 1302 browser.max_width = strtoul(optarg, NULL, 10); 1303 break; 1304 default: 1305 fprintf(stderr, "fatal: unknown flag %c\n", c); 1306 curl_url_cleanup(browser.url); 1307 return 1; 1308 } 1309 } 1310 1311 if (optind == argc - 1) { 1312 if (!set_url(&browser, argv[optind], &browser.history)) { 1313 return 1; 1314 } 1315 } else { 1316 open_bookmarks(&browser); 1317 } 1318 1319 gemini_tofu_init(&browser.tofu, &tofu_callback, &browser); 1320 1321 struct gemini_response resp; 1322 browser.running = true; 1323 while (browser.running) { 1324 static char prompt[4096]; 1325 bool request_ok = do_requests(&browser, &resp) == GEMINI_OK; 1326 again:; 1327 bool skip_prompt = request_ok && display_response(&browser, &resp); 1328 if (browser.meta) { 1329 free(browser.meta); 1330 } 1331 browser.meta = resp.status == GEMINI_STATUS_SUCCESS 1332 ? strdup(resp.meta) : NULL; 1333 if (!skip_prompt) { 1334 char *end = NULL; 1335 if (browser.meta && (end = strchr(browser.meta, ';')) != NULL) { 1336 *end = 0; 1337 } 1338 snprintf(prompt, sizeof(prompt), "\n%s at %s\n" 1339 "[N]: follow Nth link; %s%s[q]uit; [?]; or type a URL\n" 1340 "=> ", browser.meta ? browser.meta 1341 : "[request failed]", browser.plain_url, 1342 browser.history->prev ? "[b]ack; " : "", 1343 browser.history->next ? "[f]orward; " : ""); 1344 if (end != NULL) { 1345 *end = ';'; 1346 } 1347 1348 enum prompt_result result = PROMPT_AGAIN; 1349 while (result == PROMPT_AGAIN || result == PROMPT_MORE) { 1350 result = do_prompts(prompt, &browser); 1351 } 1352 switch (result) { 1353 case PROMPT_AGAIN: 1354 case PROMPT_MORE: 1355 assert(0); 1356 case PROMPT_QUIT: 1357 browser.running = false; 1358 break; 1359 case PROMPT_ANSWERED: 1360 break; 1361 case PROMPT_NEXT: 1362 case PROMPT_RELOAD: 1363 goto again; 1364 } 1365 } 1366 gemini_response_finish(&resp); 1367 } 1368 1369 gemini_tofu_finish(&browser.tofu); 1370 struct history *hist = browser.history; 1371 while (hist && hist->prev) { 1372 hist = hist->prev; 1373 } 1374 history_free(hist); 1375 links_free(&browser); 1376 headings_free(&browser); 1377 curl_url_cleanup(browser.url); 1378 free(browser.page_title); 1379 free(browser.plain_url); 1380 if (browser.meta != NULL) { 1381 free(browser.meta); 1382 } 1383 fclose(browser.tty); 1384 return 0; 1385 }