From 11f9d440bf72589253b5f19e376f3be516f676be Mon Sep 17 00:00:00 2001 From: Solderpunk Date: Tue, 29 Oct 2019 19:45:36 +0200 Subject: [PATCH] Initial implementation. --- LICENSE | 2 +- README.md | 43 ++++++++++++++++- gemini-demo.go | 123 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 gemini-demo.go diff --git a/LICENSE b/LICENSE index c2e480e..3547482 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) . All rights reserved. +Copyright (c) <2019> . All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/README.md b/README.md index ec087fb..b566200 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,44 @@ # gemini-demo-3 -Minimal but usable interactive Gemini client in almost < 100 LOC of Go \ No newline at end of file +Minimal but usable interactive Gemini client in not quite < 100 LOC +of Go. + +## Rationale + +One of the original design criteria for the Gemini protocol was that +"a basic but usable (not ultra-spartan) client should fit comfortably +within 50 or so lines of code in a modern high-level language. +Certainly not more than 100". This client was written to gauge how +close to (or far from!) that goal the initial rough specification is. + +## Capabilities + +This crude but functional client: + +* Has a minimal interactive interface for "Gemini maps" +* Will print plain text in UTF-8 +* Will NOT prompt for user input +* Will NOT follow redirects +* Will report errors +* Does NOT DO ANY validation of TLS certificates + +Non-text files are not yet handled. + +This is less functional than the Python demo (gemini-demo-1) or Lua +demo (gemini-demo-2). Further, it's the only one of these demo +clients which is not currently below 100 lines. This may be as much +due to my inexperience with Go as with the language's unavoidable +verbosity. Improvements welcome. + +## Usage + +Run the client and you'll get a prompt. Type a Gemini URL (the scheme +is implied, so simply entering e.g. `gemini.conman.org` will work) to +visit a Gemini location. + +If a Gemini menu is visited, you'll see numeric indices for links, ala +VF-1 or AV-98. Type a number to visit that link. + +There is very crude history: you can type `b` to go "back". + +Type `q` to quit. diff --git a/gemini-demo.go b/gemini-demo.go new file mode 100644 index 0000000..2c2dd4f --- /dev/null +++ b/gemini-demo.go @@ -0,0 +1,123 @@ +package main + +import ( + "bufio" + "crypto/tls" + "fmt" + "io/ioutil" + "net/url" + "os" + "strconv" + "strings" +) + +func main() { + stdinReader := bufio.NewReader(os.Stdin) + var u string // URL + links := make([]string, 0, 100) + history := make([]string, 0, 100) + for { + fmt.Print("> ") + cmd, _ := stdinReader.ReadString('\n') + cmd = strings.TrimSpace(cmd) + // Command dispatch + switch strings.ToLower(cmd) { + case "": // Nothing + continue + case "q": // Quit + fmt.Println("Bye!") + os.Exit(0) + case "b": // Back + if len(history) < 2 { + fmt.Println("No history yet!") + continue + } + u = history[len(history)-2] + history = history[0 : len(history)-2] + default: + index, err := strconv.Atoi(cmd) + if err != nil { + // Treat this as a URL + u = cmd + if !strings.HasPrefix(u, "gemini://") { + u = "gemini://" + u + } + } else { + // Treat this as a menu lookup + u = links[index-1] + } + } + // Parse URL + parsed, err := url.Parse(u) + if err != nil { + fmt.Println("Error parsing URL!") + continue + } + // Connect to server + conn, err := tls.Dial("tcp", parsed.Host+":1965", &tls.Config{InsecureSkipVerify: true}) + if err != nil { + fmt.Println("Failed to connect: " + err.Error()) + continue + } + defer conn.Close() + // Send request + conn.Write([]byte(u + "\r\n")) + // Receive and parse response header + reader := bufio.NewReader(conn) + responseHeader, err := reader.ReadString('\n') + parts := strings.Fields(responseHeader) + status, err := strconv.Atoi(parts[0][0:1]) + meta := parts[1] + // Switch on status code + switch status { + case 1, 3, 6: + // No input, redirects or client certs + fmt.Println("Unsupported feature!") + case 2: + // Successful transaction + // text/* content only + if !strings.HasPrefix(meta, "text/") { + fmt.Println("Unsupported type " + meta) + continue + } + // Read everything + bodyBytes, err := ioutil.ReadAll(reader) + if err != nil { + fmt.Println("Error reading body") + continue + } + body := string(bodyBytes) + if meta == "text/gemini" { + // Handle Gemini map + links = make([]string, 0, 100) + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(line, "=>") { + line = line[2:] + bits := strings.Fields(line) + parsedLink, err := url.Parse(bits[0]) + if err != nil { + continue + } + link := parsed.ResolveReference(parsedLink).String() + var label string + if len(bits) == 1 { + label = link + } else { + label = strings.Join(bits[1:], " ") + } + links = append(links, link) + fmt.Printf("[%d] %s\n", len(links), label) + } else { + fmt.Println(line) + } + } + } else { + // Just print any other kind of text + fmt.Print(body) + } + history = append(history, u) + case 4, 5: + fmt.Println("ERROR: " + meta) + } + } +}