ncg/network.go

298 lines
7.1 KiB
Go

package main
import (
"crypto/tls"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
)
func getURL(u *url.URL) (string, *url.URL, error) {
switch u.Scheme {
case "http", "https":
resp, err := http.Get(u.String())
if err != nil {
return "", u, err
}
defer resp.Body.Close()
loc, err := resp.Location()
if err == nil {
u = loc
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", u, err
}
return string(body), u, nil
case "gemini":
status, meta, resp, actualU := GeminiRequest(u, 0)
if meta == "error" {
return "", actualU, errors.New(resp)
} else if status == 1 {
fmt.Print(meta + ": ")
os.Exit(gemGetLine)
} else if status == 2 {
meta = strings.ToLower(meta)
if strings.Contains(meta, "html") {
return resp, actualU, nil
} else if strings.Contains(meta, "gemini") {
return gem2html(resp), actualU, nil
} else if strings.Contains(meta, "markdown") || strings.HasSuffix(u.String(), ".md") {
return md2html(resp), actualU, nil
} else {
return "<html><body><pre>" + resp + "</pre></body></html>", actualU, nil
}
} else {
return fmt.Sprintf(`<html><body><h1>%d Error (Gemini)</h1><p>%s</p></body></html>`, status, resp), actualU, nil
}
case "gopher":
isGopher = true
if u.Port() == "" {
u.Host = u.Host + ":70"
}
conn, err := net.Dial("tcp", u.Host)
if err != nil {
return "", u, err
}
defer conn.Close()
gType := "1"
p := u.Path
if len(u.Path) < 2 {
p = "/" + "\n"
} else {
gType = p[1:2]
p = p[2:] + "\n"
}
_, err = conn.Write([]byte(p))
if err != nil {
return "", u, err
}
resp, err := io.ReadAll(conn)
if err != nil {
return "", u, err
}
switch gType {
case "0":
return `<html><body><pre>` + string(resp) + `</pre></body></html>`, u, nil
case "1":
return gopher2html(string(resp)), u, nil
case "7":
fmt.Print("Query: ")
os.Exit(gophGetLine)
default:
return "", u, errors.New("Unsupported gopher type")
}
default:
return "", u, fmt.Errorf("Unsupported URL scheme: %s\n", u.Scheme)
}
return "", u, nil
}
type gopherLine struct {
url string
text string
gType rune
}
func splitGopherLine(l string) gopherLine {
var out gopherLine
parts := strings.SplitN(l, "\t", -1)
if len(parts[0]) > 0 {
out.gType = rune(parts[0][0])
}
if len(parts[0]) > 1 {
out.text = parts[0][1:]
out.text = strings.TrimRight(parts[0][1:], "\n\r ")
}
if len(parts) >= 4 {
out.url = "gopher://" + parts[2] + ":" + parts[3] + "/" + string(out.gType) + parts[1]
} else {
out.gType = 'i'
out.text = ""
}
return out
}
func gopher2html(s string) string {
var b strings.Builder
b.WriteString(`<html><body>`)
var inPre bool
for _, line := range strings.SplitN(s, "\n", -1) {
line = strings.TrimSpace(line)
if line == "." {
break
}
gl := splitGopherLine(line)
if gl.gType == 'i' && !inPre {
b.WriteString(`<pre>`)
b.WriteString(makeEntities(gl.text))
inPre = true
} else if gl.gType == 'i' {
b.WriteString("<br>")
b.WriteString(makeEntities(gl.text))
} else if inPre {
inPre = false
b.WriteString(fmt.Sprintf(`</pre><br><a href="%s">%s</a></br>`, gl.url, makeEntities(gl.text)))
} else {
b.WriteString(fmt.Sprintf(`<a href="%s">%s</a><br>`, gl.url, makeEntities(gl.text)))
}
}
b.WriteString(`</body></html>`)
return b.String()
}
func gem2html(s string) string {
var b strings.Builder
b.WriteString("<html><body>")
inPre := false
inList := false
for _, l := range strings.SplitN(s, "\n", -1) {
li := strings.HasPrefix(l, "* ")
if li && !inList {
inList = true
b.WriteString("<ul>")
} else if inList {
b.WriteString("</ul>")
}
if strings.HasPrefix(l, "### ") && len(l) > 4 {
b.WriteString("<h3>")
b.WriteString(makeEntities(l[3:]))
b.WriteString("</h3>\n")
} else if strings.HasPrefix(l, "## ") && len(l) > 3 {
b.WriteString("<h2>")
b.WriteString(makeEntities(l[2:]))
b.WriteString("</h2>\n")
} else if strings.HasPrefix(l, "# ") && len(l) > 2 {
b.WriteString("<h1>")
b.WriteString(makeEntities(l[2:]))
b.WriteString("</h1>\n")
} else if strings.HasPrefix(l, "> ") && len(l) > 2 {
b.WriteString("<blockquote>")
b.WriteString(makeEntities(l[2:]))
b.WriteString("</blockquote>\n")
} else if strings.HasPrefix(l, "=>") && len(l) > 2 {
linkline := strings.TrimSpace(l[2:])
sep := strings.IndexAny(linkline, " \t")
var body, href string
if sep < 0 {
body = linkline
href = linkline
} else {
href = strings.TrimSpace(linkline[:sep])
body = makeEntities(strings.TrimSpace(linkline[sep:]))
}
b.WriteString(fmt.Sprintf(`<a href="%s">%s</a><br>`, href, body))
} else if li && len(l) > 2 {
b.WriteString("<li>")
b.WriteString(makeEntities(l[2:]))
b.WriteString("</li>")
} else if strings.HasPrefix(l, "```") {
if inPre {
inPre = false
b.WriteString("</pre>\n")
} else {
inPre = true
b.WriteString("<pre>\n")
}
} else {
if inPre {
b.WriteString(l)
b.WriteString("\n")
} else {
b.WriteString(makeEntities(strings.TrimSpace(l)))
b.WriteString("<br>")
}
}
}
b.WriteString("</body></html>")
return b.String()
}
func makeEntities(line string) string {
line = strings.ReplaceAll(line, "<", "&lt;")
line = strings.ReplaceAll(line, ">", "&gt;")
return line
}
func md2html(s string) string {
return s
}
func GeminiRequest(u *url.URL, redirectCount int) (int, string, string, *url.URL) {
if redirectCount >= 10 {
return 3, "error", "Too many redirects", u
}
if u.Port() == "" {
u.Host = u.Host + ":1965"
}
conf := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true,
}
conn, err := tls.Dial("tcp", u.Host, conf)
if err != nil {
return -1, "error", err.Error(), u
}
defer conn.Close()
_, err = conn.Write([]byte(u.String() + "\r\n"))
if err != nil {
return -1, "error", err.Error(), u
}
res, err := io.ReadAll(conn)
if err != nil {
return -1, "error", err.Error(), u
}
resp := strings.SplitN(string(res), "\r\n", 2)
if len(resp) != 2 {
if err != nil {
return -1, "error", "Invalid response from server", u
}
}
header := strings.SplitN(resp[0], " ", 2)
if len([]rune(header[0])) != 2 {
header = strings.SplitN(resp[0], "\t", 2)
if len([]rune(header[0])) != 2 {
return -1, "error", "Invalid response format from server", u
}
}
// Get status code single digit form
status, err := strconv.Atoi(string(header[0][0]))
if err != nil {
return -1, "error", "Invalid status response from server", u
}
if status != 2 {
switch status {
case 1:
resp[1] = header[1]
case 3:
// This does not support relative redirects
// TODO add support
newUrl, err := url.Parse(header[1])
if err != nil {
resp[1] = "Redirect attempted to invalid URL"
break
}
return GeminiRequest(newUrl, redirectCount+1)
case 4:
resp[1] = fmt.Sprintf("Temporary failure; %s", header[1])
case 5:
resp[1] = fmt.Sprintf("Permanent failure; %s", header[1])
case 6:
resp[1] = "Client certificate required (unsupported by 'net-get')"
default:
resp[1] = "Invalid response status from server"
}
}
return status, header[1], resp[1], u
}