From 4bf44b16562335b3d09b6df0150521bb5b5f776f Mon Sep 17 00:00:00 2001 From: Feuerfuchs Date: Mon, 18 May 2020 12:12:43 +0200 Subject: WIP: Refactoring --- pkg/libgemini/libgemini.go | 145 +++++++++++++++++++++ pkg/libgopher/libgopher.go | 312 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 pkg/libgemini/libgemini.go create mode 100644 pkg/libgopher/libgopher.go (limited to 'pkg') diff --git a/pkg/libgemini/libgemini.go b/pkg/libgemini/libgemini.go new file mode 100644 index 0000000..303490c --- /dev/null +++ b/pkg/libgemini/libgemini.go @@ -0,0 +1,145 @@ +package libgemini + +import ( + "bufio" + "crypto/tls" + "errors" + "fmt" + "io" + "mime" + "net" + "net/url" + "regexp" + "strconv" + "strings" +) + +const ( + CRLF = "\r\n" +) + +const ( + STATUS_INPUT = 10 + STATUS_SUCCESS = 20 + STATUS_SUCCESS_CERT = 21 + STATUS_REDIRECT_TEMP = 30 + STATUS_REDIRECT_PERM = 31 + STATUS_TEMP_FAILURE = 40 + STATUS_SERVER_UNAVAILABLE = 41 + STATUS_CGI_ERROR = 42 + STATUS_PROXY_ERROR = 43 + STATUS_SLOW_DOWN = 44 + STATUS_PERM_FAILURE = 50 + STATUS_NOT_FOUND = 51 + STATUS_GONE = 52 + STATUS_PROXY_REFUSED = 53 + STATUS_BAD_REQUEST = 59 + STATUS_CLIENT_CERT_EXPIRED = 60 + STATUS_TRANSIENT_CERT_REQUEST = 61 + STATUS_AUTH_CERT_REQUIRED = 62 + STATUS_CERT_REJECTED = 63 + STATUS_FUTURE_CERT_REJECTED = 64 + STATUS_EXPIRED_CERT_REJECTED = 65 +) + +const ( + MIME_GEMINI = "text/gemini" + DEFAULT_MIME = MIME_GEMINI + DEFAULT_CHARSET = "utf-8" +) + +var ( + HeaderPattern = regexp.MustCompile("^(\\d\\d)[ \\t]+(.*)$") + LinkPattern = regexp.MustCompile("^=>[ \\t]*([^ \\t]+)(?:[ \\t]+(.*))?$") +) + +type Header struct { + Status int + Meta string +} + +type Response struct { + Header *Header + Body io.Reader +} + +func Get(uri string) (*Response, error) { + u, err := url.Parse(uri) + if err != nil { + return nil, err + } + + if u.Scheme != "gemini" { + return nil, errors.New("invalid scheme for uri") + } + + host := u.Hostname() + port := u.Port() + + if port == "" { + port = "1965" + } + + conn, err := tls.Dial("tcp", net.JoinHostPort(host, port), &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, + }) + if err != nil { + return nil, err + } + + _, err = conn.Write([]byte(u.String() + CRLF)) + if err != nil { + conn.Close() + return nil, err + } + + reader := bufio.NewReader(conn) + + line, _, err := reader.ReadLine() + if err != nil { + conn.Close() + return nil, err + } + + header, err := ParseHeader(string(line)) + if err != nil { + conn.Close() + return nil, err + } + + return &Response{ + Header: header, + Body: reader, + }, nil +} + +func ParseHeader(line string) (header *Header, err error) { + matches := HeaderPattern.FindStringSubmatch(line) + + status, err := strconv.Atoi(matches[1]) + if err != nil { + return nil, err + } + + meta := matches[2] + + if int(status/10) == 2 { + mediaType, params, err := mime.ParseMediaType(meta) + + if err != nil { + meta = fmt.Sprintf("%s;charset=%s", DEFAULT_MIME, DEFAULT_CHARSET) + } else if strings.HasPrefix(mediaType, "text/") { + if _, ok := params["charset"]; !ok { + meta += ";charset=" + DEFAULT_CHARSET + } + } + } + + header = &Header{ + Status: status, + Meta: meta, + } + + return +} diff --git a/pkg/libgopher/libgopher.go b/pkg/libgopher/libgopher.go new file mode 100644 index 0000000..86d58ff --- /dev/null +++ b/pkg/libgopher/libgopher.go @@ -0,0 +1,312 @@ +package libgopher + +import ( + "bufio" + "errors" + "io" + "log" + "net" + "net/url" + "strings" +) + +// Item Types +const ( + FILE = ItemType('0') // Item is a file + DIRECTORY = ItemType('1') // Item is a directory + PHONEBOOK = ItemType('2') // Item is a CSO phone-book server + ERROR = ItemType('3') // Error + BINHEX = ItemType('4') // Item is a BinHexed Macintosh file. + DOSARCHIVE = ItemType('5') // Item is DOS binary archive of some sort. (*) + UUENCODED = ItemType('6') // Item is a UNIX uuencoded file. + INDEXSEARCH = ItemType('7') // Item is an Index-Search server. + TELNET = ItemType('8') // Item points to a text-based telnet session. + BINARY = ItemType('9') // Item is a binary file! (*) + + // (*) Client must read until the TCP connection is closed. + + REDUNDANT = ItemType('+') // Item is a redundant server + TN3270 = ItemType('T') // Item points to a text-based tn3270 session. + GIF = ItemType('g') // Item is a GIF format graphics file. + IMAGE = ItemType('I') // Item is some kind of image file. + + // non-standard + INFO = ItemType('i') // Item is an informational message + HTML = ItemType('h') // Item is a HTML document + AUDIO = ItemType('s') // Item is an Audio file + PNG = ItemType('p') // Item is a PNG Image + DOC = ItemType('d') // Item is a Document +) + +const ( + // END represents the terminator used in directory responses + END = byte('.') + + // TAB is the delimiter used to separate item response parts + TAB = byte('\t') + + // CRLF is the delimiter used per line of response item + CRLF = "\r\n" + + // DEFAULT is the default item type + DEFAULT = BINARY +) + +// ItemType represents the type of an item +type ItemType byte + +// Return a human friendly represation of an ItemType +func (it ItemType) String() string { + switch it { + case FILE: + return "TXT" + case DIRECTORY: + return "DIR" + case PHONEBOOK: + return "PHO" + case ERROR: + return "ERR" + case BINHEX: + return "HEX" + case DOSARCHIVE: + return "ARC" + case UUENCODED: + return "UUE" + case INDEXSEARCH: + return "QRY" + case TELNET: + return "TEL" + case BINARY: + return "BIN" + case REDUNDANT: + return "DUP" + case TN3270: + return "TN3" + case GIF: + return "GIF" + case IMAGE: + return "IMG" + case INFO: + return "NFO" + case HTML: + return "HTM" + case AUDIO: + return "SND" + case PNG: + return "PNG" + case DOC: + return "DOC" + default: + return "???" + } +} + +// Item describes an entry in a directory listing. +type Item struct { + Type ItemType `json:"type"` + Description string `json:"description"` + Selector string `json:"selector"` + Host string `json:"host"` + Port string `json:"port"` + + // non-standard extensions (ignored by standard clients) + Extras []string `json:"extras"` +} + +// ParseItem parses a line of text into an item +func ParseItem(line string) (item *Item, err error) { + parts := strings.Split(strings.Trim(line, "\r\n"), "\t") + + if len(parts[0]) < 1 { + return nil, errors.New("no item type: " + string(line)) + } + + item = &Item{ + Type: ItemType(parts[0][0]), + Description: string(parts[0][1:]), + Extras: make([]string, 0), + } + + // Selector + if len(parts) > 1 { + item.Selector = string(parts[1]) + } else { + item.Selector = "" + } + + // Host + if len(parts) > 2 { + item.Host = string(parts[2]) + } else { + item.Host = "null.host" + } + + // Port + if len(parts) > 3 { + item.Port = string(parts[3]) + } else { + item.Port = "0" + } + + // Extras + if len(parts) >= 4 { + for _, v := range parts[4:] { + item.Extras = append(item.Extras, string(v)) + } + } + + return +} + +func (i *Item) isDirectoryLike() bool { + switch i.Type { + case DIRECTORY: + return true + case INDEXSEARCH: + return true + default: + return false + } +} + +// Directory representes a Gopher Menu of Items +type Directory struct { + Items []*Item `json:"items"` +} + +// Response represents a Gopher resource that +// Items contains a non-empty array of Item(s) +// for directory types, otherwise the Body +// contains the fetched resource (file, image, etc). +type Response struct { + Type ItemType + Dir Directory + Body io.Reader +} + +// Get fetches a Gopher resource by URI +func Get(uri string) (*Response, error) { + u, err := url.Parse(uri) + if err != nil { + return nil, err + } + + if u.Scheme != "gopher" { + return nil, errors.New("invalid scheme for uri") + } + + host := u.Hostname() + port := u.Port() + + if port == "" { + port = "70" + } + + var ( + Type ItemType + Selector string + ) + + path := strings.TrimPrefix(u.Path, "/") + if len(path) > 2 { + Type = ItemType(path[0]) + Selector = path[1:] + if u.RawQuery != "" { + Selector += "\t" + u.RawQuery + } + } else if len(path) == 1 { + Type = ItemType(path[0]) + Selector = "" + } else { + Type = ItemType(DIRECTORY) + Selector = "" + } + + i := Item{Type: Type, Selector: Selector, Host: host, Port: port} + res := Response{Type: i.Type} + + if i.isDirectoryLike() { + d, err := i.FetchDirectory() + if err != nil { + return nil, err + } + + res.Dir = d + } else { + reader, err := i.FetchFile() + if err != nil { + return nil, err + } + + res.Body = reader + } + + return &res, nil +} + +// FetchFile fetches data, not directory information. +// Calling this on a DIRECTORY Item type +// or unsupported type will return an error. +func (i *Item) FetchFile() (io.Reader, error) { + if i.Type == DIRECTORY { + return nil, errors.New("cannot fetch a directory as a file") + } + + conn, err := net.Dial("tcp", net.JoinHostPort(i.Host, i.Port)) + if err != nil { + return nil, err + } + + _, err = conn.Write([]byte(i.Selector + CRLF)) + if err != nil { + conn.Close() + return nil, err + } + + return conn, nil +} + +// FetchDirectory fetches directory information, not data. +// Calling this on an Item whose type is not DIRECTORY will return an error. +func (i *Item) FetchDirectory() (Directory, error) { + if !i.isDirectoryLike() { + return Directory{}, errors.New("cannot fetch a file as a directory") + } + + conn, err := net.Dial("tcp", net.JoinHostPort(i.Host, i.Port)) + if err != nil { + return Directory{}, err + } + + _, err = conn.Write([]byte(i.Selector + CRLF)) + if err != nil { + return Directory{}, err + } + + reader := bufio.NewReader(conn) + scanner := bufio.NewScanner(reader) + scanner.Split(bufio.ScanLines) + + var items []*Item + + for scanner.Scan() { + line := strings.Trim(scanner.Text(), "\r\n") + + if len(line) == 0 { + continue + } + + if len(line) == 1 && line[0] == END { + break + } + + item, err := ParseItem(line) + if err != nil { + log.Printf("Error parsing %q: %q", line, err) + continue + } + items = append(items, item) + } + + return Directory{items}, nil +} -- cgit v1.2.3-70-g09d2