Initial implementation.
This commit is contained in:
parent
65c546e3ea
commit
11f9d440bf
2
LICENSE
2
LICENSE
|
@ -1,4 +1,4 @@
|
|||
Copyright (c) <year> <owner> . All rights reserved.
|
||||
Copyright (c) <2019> <solderpunk@sdf.org> . All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
|
43
README.md
43
README.md
|
@ -1,3 +1,44 @@
|
|||
# gemini-demo-3
|
||||
|
||||
Minimal but usable interactive Gemini client in almost < 100 LOC of Go
|
||||
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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue