// Thousand line gopher client. (Well, 330 of those are comments or empty) // Similar idea to my thousand line browser, but sigificantly more feature // complete. Currently supports: // // - Gopher menus (duh) // - Text viewer with pagination and word wrapping // - Downloading files and text // - Interupting downloads (no need to ^C if a server went down) // - Bookmarks // // Compiling: gcc gophertree.c -gdwarf -Wall -lncursesw -o gophertree // Usage: ./gophertree [maurycyz.com] [70] [/] // // If no path is specified, it opens your bookmark file. // You can add a selector to this file with 'a' from any menu. This bookmark // file is just a normal gopher menu, so it can be edited manually if needed. // // Unlike most clients, it does not use a web style "menu = page" interface. // Menus are displayed as a directory tree, with linked menus nested inside the // higher level menu. // // By default, it does not display "i"nformation selectors. While some servers // use these for actual information, way too many have massive ASCII art // banners in each menu. These look cute in browsers, but are very annoying // in a directory tree. // // Menus are assumed to be UTF-8 encoded, but text files can be viewed as // Latin-1 if needed by pressing 'e' // // Menu controls // Up/down : Move cursor in menus // Right : Open submenu, view text, or download file. // Left : Hide submenu and cancel loading // Pg up/down : Skip to the top or bottom of current submenu // 'q' : Exit client // 'a' : Add to bookmarks // 'd' : Remove selected bookmark // 'r' : Rename selected bookmark // 'i' : Toggle informational selector display // // Text viewer controls // Up/down : Move cursor in menus // Left : Exit // 's' : Save text to files // 'e' : Toggle UTF-8/Latin 1 mode // // WARNING: No attempt is made to detect when two instances are using the // boomkark file. Any edit will write the state as show in that instance // to disk. // // WARNING: All data is sent as plaintext. Other people on your network may // be eavesdroping, and MITM attacks would allow content injection. #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include // These should work fine on linux, but may need changing along with // find_path() to run on anything else. char* bookmark_file = "/.local/share/gopherbookmarks"; char* bookmark_path = NULL; // Latin-1 to UTF-8 table const char* latin1[128] = { " "," "," "," "," "," "," "," "," "," "," "," "," "," "," "," ", " "," "," "," "," "," "," "," "," "," "," "," "," "," "," "," ", " ","¡","¢","£","¤","¥","¦","§","¨","©","ª","«","¬","", "®","¯", "°","±","²","³","´","µ","¶","·","¸","¹","º","»","¼","½","½","¿", "À","Á","Â","Ã","Ä","Å","Æ","Ç","È","É","Ê","Ë","Ì","Í","Î","Ï", "Ð","Ñ","Ò","Ó","Ô","Õ","Ö","×","Ø","Ù","Ú","Û","Ü","Ý","Þ","ß", "à","á","â","ã","ä","å","æ","ç","è","é","ê","ë","ì","í","î","ï", "ð","ñ","ò","ó","ô","õ","ö","÷","ø","ù","ú","û","ü","ý","þ","ÿ" }; char* convert_to_utf8(uint8_t* badstring) { // Calculate new size in bytes int len = 0; for (int i = 0; badstring[i]; i++) { if (badstring[i] & 0b10000000) { len += strlen(latin1[badstring[i] & 127]); } else { len += 1; } } char* converted = malloc(sizeof(char) * (len + 1)); int bytes = 0; // Create the new utf-8ified string: for (int i = 0; badstring[i]; i++) { if (badstring[i] & 0b10000000) { const char* utf8 = latin1[badstring[i] & 127]; memcpy(&converted[bytes], utf8, strlen(utf8)); bytes += strlen(utf8); } else { converted[bytes] = badstring[i]; bytes ++; } } return converted; } char* box_substring(char* orig, int length) { char* new = malloc(length + 1); strncpy(new, orig, length + 1); new[length] = 0; return new; } char* box_string(char* orig) { int size = strlen(orig); return box_substring(orig, size); } /////////////////////////////////////////////////////////////////////////////// // // // Gopher menu parsing // // // /////////////////////////////////////////////////////////////////////////////// enum type { Dir, // Menus Search, // Search menus Info, // Informational lines Data, // Binary files/images/etc Text, // Text files Unsupported, // Things we can't load }; struct menu_entry { int type; char* name; char* selector; char* host; int port; struct menu* contains; // For directories, what's inside them. int hide_subdirs; // UI state char* search; // Search string }; struct menu { int len; struct menu_entry entry[]; }; // Put everything until a tab or newline on the heap. char* box_gopher_field(char** str) { int len = 0; char* s = *str; while (s[len]) { if (s[len] == '\r' || s[len] == '\n' || s[len] == '\t') break; len++; } char* field = box_substring(*str, len); *str += len; return field; } struct menu* parse_menu(char* string) { int len = 0; struct menu* menu = NULL; while (*string) { // Stop parsing on a lone dot if (*string == '.') break; // Skip blank lines if (*string == '\r' || *string == '\n') { string++; continue; } // If we are still here, this is a menu entry len++; menu = realloc(menu, sizeof(struct menu) + sizeof(struct menu_entry) * len); struct menu_entry* this = &menu->entry[len - 1]; // Parse type switch (*string) { case '0': case '3': this->type = Text; break; case '1': case '+': this->type = Dir; break; case '7': this->type = Search; break; case 'h': this->type = Unsupported; break; case 'i': this->type = Info; break; default: this->type = Data; break; } // Advance past type if (*string) string++; this->name = NULL; this->selector = ""; this->host = NULL; this->port = 70; this->contains = NULL; this->hide_subdirs = 0; this->search = NULL; // Parse each field, stopping on newlines or EOFs this->name = box_gopher_field(&string); if (*string == '\r' || *string == '\n' || *string == 0) continue; if (*string == '\t') string++; this->selector = box_gopher_field(&string); if (*string == '\r' || *string == '\n' || *string == 0) continue; if (*string == '\t') string++; this->host = box_gopher_field(&string); if (*string == '\r' || *string == '\n' || *string == 0) continue; if (*string == '\t') string++; this->port = atoi(string); // Skip any trailing data while (*string && *string != '\n') string++; } if (!menu) menu = malloc(sizeof(struct menu)); menu->len = len; return menu; } /////////////////////////////////////////////////////////////////////////////// // // // Bookmarking // // // /////////////////////////////////////////////////////////////////////////////// void find_path() { if (!bookmark_path) { const char *homedir; if ((homedir = getenv("HOME")) == NULL) { homedir = getpwuid(getuid())->pw_dir; } int len = strlen(homedir) + strlen(bookmark_file) + 1; bookmark_path = malloc(len); snprintf(bookmark_path, len, "%s%s", homedir, bookmark_file); } } struct menu* read_bookmarks() { find_path(); // Something to get you started char* bookmarks = box_string("1Maurycy's stuff\t/\tmaurycyz.com\t70\r\n"); FILE* file = fopen(bookmark_path, "r"); if (file) { fseek(file, 0, SEEK_END); int len = ftell(file) + 1; rewind(file); bookmarks = realloc(bookmarks, len); fread(bookmarks, len, 1, file); fclose(file); }; struct menu* parsed = parse_menu(bookmarks); free(bookmarks); return parsed; } void write_bookmarks(struct menu* menu) { FILE* file = fopen(bookmark_path, "w"); char line[1024]; for (int i = 0; i < menu->len; i++) { struct menu_entry bookmark = menu->entry[i]; char type; switch (bookmark.type) { case Text: type = '0'; break; case Dir: type = '1'; break; case Search: type = '7'; break; default: type = '9'; break; } snprintf(line, 1024, "%c%s\t%s\t%s\t%d\r\n", type, bookmark.name, bookmark.selector, bookmark.host, bookmark.port); fwrite(line, strlen(line), 1, file); } fclose(file); } /////////////////////////////////////////////////////////////////////////////// // // // Networking // // // /////////////////////////////////////////////////////////////////////////////// struct download_state { struct menu_entry target; char* state; int error; char* error_str; int fd; // In case we have to stop it. int done; uint8_t* data; uint64_t size; }; void send_str(int fd, char* string) { send(fd, string, strlen(string), 0); } // Load a gopher entry, but don't parse it. // On error, returns null and sets neterr to a descriptive string. void* thread_start(void* ptr) { int fd, status; struct download_state *this = ptr; struct menu_entry target = this->target; if (!target.host) { this->error = 1; this->error_str = "Missing hostname!"; return NULL; } // Resolve address struct addrinfo hints, *results, *rp; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; this->state = "Resolving hostname..."; int s = getaddrinfo(target.host, NULL, &hints, &results); if (s != 0) { this->error = 1; this->error_str = "Could not resolve hostname."; return NULL; } // Try addresses until one works fd = -1; this->state = "Connecting..."; for (rp = results; rp != NULL; rp = rp->ai_next) { char ipstr[INET6_ADDRSTRLEN]; struct sockaddr* serv_addr; serv_addr = rp->ai_addr; int type = rp->ai_addr->sa_family; // Set port if (type == AF_INET) { struct sockaddr_in* addr = (struct sockaddr_in*) serv_addr; addr->sin_port = htons(target.port); inet_ntop(type, &addr->sin_addr, ipstr, sizeof(ipstr)); } else if (type == AF_INET6) { struct sockaddr_in6* addr = (struct sockaddr_in6*) serv_addr; addr->sin6_port = htons(target.port); inet_ntop(type, &addr->sin6_addr, ipstr, sizeof(ipstr)); } else continue; if ((fd = socket(type, SOCK_STREAM, 0)) < 0) { this->error = 1; this->error_str = "Failed to create socket."; return NULL; } if ((status = connect(fd, serv_addr, rp->ai_addrlen) != 0)) { close(fd); fd = -1; } else break; } this->fd = fd; if (fd == -1) { this->error = 1; this->error_str = "No address worked"; close(fd); return NULL; } freeaddrinfo(results); // Send request this->state = "Sending request..."; send_str(fd, target.selector); if (target.search) { send_str(fd, "\t"); send_str(fd, target.search); } send_str(fd, "\r\n"); // Read data this->state = "Receiving..."; uint8_t* data = NULL; int size = 0; while (1) { data = realloc(data, size + 1025); // Intentionally 2**10 + 1. int read = recv(fd, &data[size], 1024, 0); if (read == -1) { this->error_str = "Read failed."; this->error = 1; free(data); return 0; } size += read; this->size = size; if (read <= 0) break; } close(fd); // Null terminate (ok because we allocate one byte too many while reading) data[size] = 0; this->done = 1; this->data = data; return 0; } struct download_state* start_loading(struct menu_entry target, pthread_t* thread) { struct download_state* state = malloc(sizeof(struct download_state)); state->state = "Starting..."; state->target = target; state->done = 0; state->error = 0; state->data = NULL; state->size = 0; pthread_create(thread, NULL, thread_start, (void*)state); return state; } /////////////////////////////////////////////////////////////////////////////// // // // GUI // // // /////////////////////////////////////////////////////////////////////////////// int show_info = 0; // Used to delay refresh of the status bar. int has_message = 0; // Curses window for the directory tree WINDOW* tree = NULL; WINDOW* title = NULL; // Curses color pairs enum colors { C_DEFAULT = 0, C_SELECTED, C_TITLE, C_GRAY, }; struct menu_entry* selected = NULL; // Pointer to selected item. // Simple one line file loading UI // Shows progress and allows canceling the transfer uint8_t* load_blocking(struct menu_entry target, int* size) { pthread_t thread; struct download_state *state = start_loading(target, &thread); while (!state->done && !state->error) { wmove(title, 0, 0); char status[COLS]; snprintf(status, COLS, "Loading: %s [%d kB]\n", state->state, (int)(state->size/1024)); waddstr(title, status); wrefresh(title); timeout(100); int c = getch(); timeout(-1); if (c == KEY_LEFT) { // Stop thread pthread_cancel(thread); pthread_join(thread, NULL); // Clean up free(state); wmove(title, 0, 0); waddstr(title, "Canceled!\n"); wrefresh(title); has_message = 1; return 0; } } pthread_join(thread, NULL); // Display errors for 2 second, or until a keypress if (state->error) { wmove(title, 0, 0); waddstr(title, "Error: "); waddstr(title, state->error_str); waddstr(title, "\n"); wrefresh(title); has_message = 1; } uint8_t* data = state->data; *size = state->size; free(state); return data; } /////////////////////////////////////////////////////////////////////////////// // // // GUI: Text viewer // // // /////////////////////////////////////////////////////////////////////////////// int wordlen(char* c) { for (int i = 0;; i++) { if (c[i] == 0) return i; if (c[i] == ' ') return i; if (c[i] == '\n') return i; if (c[i] == '\r') return i; } } // Text viewer UI void show_text(char* text) { // "Incremental" pageination. // Render text until no more fits on screen, and then save where that happens. // Repeat everytime the user scrolls. // // We save the old page pointers to allow scrolling up int n_pages = 1; char** pages = malloc(sizeof(char*)); pages[0] = text; int page = 0; int reached_end = 0; while (1) { werase(tree); // Where to wrap text int max_line_length = getmaxx(tree); char* str = pages[page]; while (*str) { // Handle newlines if (*str == '\r') {str++;} // Insert a newline if needeAd int word_len = wordlen(str); if (getcurx(tree) + word_len > max_line_length) { waddstr(tree, "\n"); } // Check if we need to start a new page if (getmaxy(tree) - 1 <= getcury(tree)) { if (page + 1 == n_pages) { n_pages++; pages = realloc(pages, sizeof(char*) * n_pages); pages[n_pages - 1] = str; } break; } // Write the word to the terminal waddnstr(tree, str, word_len); str += word_len; if (*str && *str != '\r') { waddch(tree, *str); str++; } // Check if we need to start a new page if (getmaxy(tree) - 1 <= getcury(tree)) { if (page + 1 == n_pages) { n_pages++; pages = realloc(pages, sizeof(char*) * n_pages); pages[n_pages - 1] = str; } break; } } if (!*str) reached_end = 1; // Title bar wmove(title, 0, 0); wattron(title, COLOR_PAIR(C_TITLE)); if (selected) { char preview[COLS]; if (reached_end) { snprintf(preview, COLS, "[TEXT VIEWER] %s:%d %s Page: %d/%d\n", selected->host, selected->port, selected->selector, page, n_pages); } else { snprintf(preview, COLS, "[TEXT VIEWER] %s:%d %s Page: %d/???\n", selected->host, selected->port, selected->selector, page); } waddstr(title, preview); } refresh(); wrefresh(title); wrefresh(tree); int c = getch(); if (c == KEY_LEFT) break; if (c == KEY_DOWN) { if (page < n_pages - 1) page ++; } if (c == KEY_UP) { if (page > 0) page --; } if (c == 'e') { // Encoding switch n_pages = 1; page = 0; if (pages[0] == text) { pages[0] = convert_to_utf8((uint8_t*)text); } else { pages[0] = text; } } if (c == 's') { wmove(title, 0, 0); waddstr(title, "Save to file: \n"); wrefresh(title); char path[256]; echo(); wgetnstr(title, path, 256); noecho(); if (strlen(path) == 0) continue; FILE* dst = fopen(path, "w"); fwrite(text, strlen(text), 1, dst); fclose(dst); } } } /////////////////////////////////////////////////////////////////////////////// // // // GUI: Menus // // // /////////////////////////////////////////////////////////////////////////////// // Render the directory tree and grab information from it int line_num = 0; // Current line number int start_line = 0; // Range of numbers that should be inside the viewport int cursor = 0; // Selected item number int cursor_line = 0; // Cursor postion on screen int cursor_max = 0; // Number of selectable items int subdir_bounds[2]; // Top and bottom void indent(int depth, int is_dir) { for (int i = 0; i < depth; i++) { if (i + 1 == depth && is_dir) { waddstr(tree, " +"); } else { waddstr(tree, " |"); } } } #define KEEP_DRAWING (getcury(tree) < getmaxy(tree) - 1) void print_menu(struct menu* menu, int depth) { int menu_start = cursor_max; int had_cursor = 0; // Draw empty menus if (menu->len == 0) { line_num++; if (line_num >= start_line && KEEP_DRAWING) { indent(depth, 0); waddstr(tree, "(empty)\n"); } } // Draw normal menues for (int i = 0; i < menu->len; i++) { struct menu_entry this = menu->entry[i]; if (this.type == Info && !show_info) continue; if (cursor == cursor_max && this.type != Info) { wattron(tree, COLOR_PAIR(C_SELECTED)); selected = &menu->entry[i]; cursor_line = getcury(tree); had_cursor = 1; } if (this.type != Info) { cursor_max ++; } line_num++; if (line_num >= start_line && KEEP_DRAWING) { indent(depth, this.type == Dir); switch (this.type) { case Dir: if (this.contains && !this.hide_subdirs) waddstr(tree, "--+DIR/"); else waddstr(tree, "-->DIR/"); break; case Info: waddstr(tree, " i "); break; case Data: waddstr(tree, " BIN "); break; case Text: waddstr(tree, " TXT "); break; case Search: waddstr(tree, " ? "); break; case Unsupported: waddstr(tree, " UNK "); break; } if (this.type == Info) { // wattron(tree, A_ITALIC); } else { wattron(tree, A_BOLD); } waddstr(tree, this.name); waddstr(tree, "\n"); wattroff(tree, COLOR_PAIR(C_SELECTED)); wattroff(tree, A_ITALIC); wattroff(tree, A_BOLD); } if (this.contains && !this.hide_subdirs) { print_menu(this.contains, depth + 1); } } if (had_cursor) { subdir_bounds[0] = menu_start; subdir_bounds[1] = cursor_max - 1; } } // Main browser UI int main(int argc, char** argv) { // Setup curses setlocale(LC_ALL, ""); initscr(); cbreak(); noecho(); start_color(); keypad(stdscr, TRUE); init_pair(C_SELECTED, COLOR_YELLOW, COLOR_BLACK); init_pair(C_TITLE, COLOR_BLACK, COLOR_WHITE); tree = newwin(LINES - 1, COLS, 1, 0); title = newwin(1, COLS, 0, 0); start_line = 0; struct menu *bookmarks = read_bookmarks(); struct menu *root = bookmarks; int root_is_bookmarks = 1; if (argc > 1) { struct menu_entry start = { .type = Dir, .host = NULL, .selector = "/", .port = 70, .contains = NULL, }; start.host = argv[1]; if (argc > 2) start.port = atoi(argv[2]); if (argc > 3) start.selector = argv[3]; int size; uint8_t* raw = load_blocking(start, &size); if (!raw) { endwin(); printf("Failed to load starting menu.\n"); printf("Usage: %s [hostname] [port] [selector]\n", argv[0]); exit(0); } root = parse_menu((char*)raw); free(raw); root_is_bookmarks = 0; } while (1) { // Draw directory tree draw: line_num = 0; cursor_max = 0; werase(tree); wmove(tree, 0, 0); print_menu(root, 0); // Keep cursor within a region of the screen. int top = LINES/3; int bottom = LINES*2/3; if (cursor_line > bottom) { start_line += cursor_line - bottom; // Redraw if needed goto draw; } if (cursor_line < top) { int old = start_line; start_line -= top - cursor_line; // Have to be careful to not make the start line negative if (start_line < 0) { start_line = 0; } // Redraw if needed if (old != start_line) goto draw; } // Link previews wmove(title, 0, 0); wattron(title, COLOR_PAIR(C_TITLE)); if (selected) { char preview[COLS]; snprintf(preview, COLS, "%s:%d %s\n", selected->host, selected->port, selected->selector); waddstr(title, preview); } // Draw to terminal. refresh(); if (!has_message) { // Delay title bar updates so user can see messages. wrefresh(title); } else { has_message = 0; } wrefresh(tree); int c = getch(); if (c == 'q') break; // Navigation keys if (c == KEY_UP && cursor > 0) cursor--; if (c == KEY_DOWN && cursor < cursor_max - 1) cursor++; if (c == KEY_NPAGE) { cursor = subdir_bounds[1]; } if (c == KEY_PPAGE) { cursor = subdir_bounds[0]; } // Loading files... if (c == KEY_RIGHT && selected) { if (selected->type == Dir || selected->type == Search) { if (selected->type == Search) { // Prompt user for search string wmove(title, 0, 0); waddstr(title, "Search: \n"); wrefresh(title); char search[256]; echo(); wgetnstr(title, search, 256); noecho(); if (strlen(search) == 0) continue; selected->search = search; } // Only reload if it's not aready loaded if (!selected->contains || selected->type == Search) { int size; uint8_t* raw = load_blocking(*selected, &size); if (raw) { selected->contains = parse_menu((char*)raw); free(raw); } } // Ensure what we just loaded is shown selected->hide_subdirs = 0; } else if (selected->type == Text){ // Run paginated text viewer int len; char* text = (char*)load_blocking(*selected, &len); if (text) { show_text(text); free(text); } } else if (selected->type == Data) { // Prompt user for filename wmove(title, 0, 0); waddstr(title, "Save to file: \n"); wrefresh(title); char path[256]; echo(); wgetnstr(title, path, 256); noecho(); if (strlen(path) == 0) continue; int size; uint8_t* raw = load_blocking(*selected, &size); if (raw) { FILE* dst = fopen(path, "w"); fwrite(raw, size, 1, dst); fclose(dst); wmove(title, 0, 0); waddstr(title, "Saved.\n"); wrefresh(title); has_message = 1; } } } if (c == KEY_LEFT && selected) { selected->hide_subdirs = 1; } if (c == 'a') { // Bookmark append int len = bookmarks->len + 1; bookmarks = realloc(bookmarks, sizeof(struct menu_entry) * len + sizeof(struct menu)); if (root_is_bookmarks) root = bookmarks; // Realloc may have moved it. bookmarks->entry[len - 1] = *selected; bookmarks->entry[len - 1].contains = NULL; bookmarks->len = len; // Save it to disk write_bookmarks(bookmarks); wmove(title, 0, 0); waddstr(title, "Added!\n"); wrefresh(title); has_message = 1; } if (c == 'd') { // Bookmark remove int found = 0; int len = bookmarks->len; for (int i = 0; i < len; i++) { if (&bookmarks->entry[i] == selected) { memcpy(&bookmarks->entry[i], &bookmarks->entry[i + 1], sizeof(struct menu_entry) * (len - i - 1)); len --; found = 1; } } bookmarks->len = len; if (found) { // Save it to disk write_bookmarks(bookmarks); wmove(title, 0, 0); waddstr(title, "Removed.\n"); wrefresh(title); has_message = 1; } } // Toggle informational selectors if (c == 'i') { show_info = show_info?0:1; } if (c == 'r') { // Bookmark rename wmove(title, 0, 0); waddstr(title, "New name: \n"); wrefresh(title); char name[256]; echo(); wgetnstr(title, name, 256); noecho(); if (strlen(name) == 0) continue; free(selected->name); selected->name = box_string(name); write_bookmarks(bookmarks); } } endwin(); return 0; }