package gopherproxy import ( "bufio" "bytes" "crypto/md5" "fmt" "html" "html/template" "io" "io/ioutil" "log" "mime" "net/http" "net/url" "regexp" "strings" "golang.org/x/net/html/charset" "golang.org/x/text/transform" "github.com/temoto/robotstxt" "github.com/prologic/go-gopher" "github.com/gobuffalo/packr/v2" "github.com/davidbyttow/govips/pkg/vips" "github.com/NYTimes/gziphandler" ) const ( ITEM_TYPE_GEMINI_LINE = "" ITEM_TYPE_GEMINI_LINK = " =>" ) type Item struct { Link template.URL Type string Text string } type AssetList struct { Style string JS string FontW string FontW2 string PropFontW string PropFontW2 string } func resolveURI(uri string, baseURL *url.URL) (resolvedURI string) { if strings.HasPrefix(uri, "//") { resolvedURI = "/gemini/" + strings.TrimPrefix(uri, "//") } else if strings.HasPrefix(uri, "gemini://") { resolvedURI = "/gemini/" + strings.TrimPrefix(uri, "gemini://") } else if strings.HasPrefix(uri, "gopher://") { resolvedURI = "/gopher/" + strings.TrimPrefix(uri, "gopher://") } else { url, err := url.Parse(uri) if err != nil { return "" } adjustedURI := baseURL.ResolveReference(url) if adjustedURI.Scheme == "gemini" { resolvedURI = "/gemini/" + adjustedURI.Host + adjustedURI.Path } else if adjustedURI.Scheme == "gopher" { resolvedURI = "/gopher/" + adjustedURI.Host + adjustedURI.Path } else { resolvedURI = adjustedURI.String() } } return } func renderGopherDirectory(w http.ResponseWriter, tpl *template.Template, assetList AssetList, uri string, hostport string, d gopher.Directory) error { var title string out := make([]Item, len(d.Items)) for i, x := range d.Items { if x.Type == gopher.INFO && x.Selector == "TITLE" { title = x.Description continue } tr := Item{ Text: x.Description, Type: x.Type.String(), } if x.Type == gopher.INFO { out[i] = tr continue } if strings.HasPrefix(x.Selector, "URL:") { tr.Link = template.URL(x.Selector[4:]) } else if strings.HasPrefix(x.Selector, "/URL:") { tr.Link = template.URL(x.Selector[5:]) } else { var hostport string if x.Port == 70 { hostport = x.Host } else { hostport = fmt.Sprintf("%s:%d", x.Host, x.Port) } path := url.PathEscape(x.Selector) path = strings.Replace(path, "%2F", "/", -1) tr.Link = template.URL( fmt.Sprintf( "/gopher/%s/%s%s", hostport, string(byte(x.Type)), path, ), ) } out[i] = tr } if title == "" { if uri != "" { title = fmt.Sprintf("%s/%s", hostport, uri) } else { title = hostport } } return tpl.Execute(w, struct { Title string URI string Assets AssetList Lines []Item RawText string Error bool Protocol string }{title, fmt.Sprintf("%s/%s", hostport, uri), assetList, out, "", false, "gopher"}) } func parseGeminiDocument(body *bytes.Buffer, uri string, hostport string) (items []Item) { baseURL, err := url.Parse(fmt.Sprintf( "gemini://%s/%s", hostport, uri, )) if err != nil { return []Item{} } scanner := bufio.NewScanner(body) for scanner.Scan() { line := strings.Trim(scanner.Text(), "\r\n") item := Item{ Type: ITEM_TYPE_GEMINI_LINE, Text: line, } linkMatch := GeminiLinkPattern.FindStringSubmatch(line) if len(linkMatch) != 0 && linkMatch[0] != "" { item.Type = ITEM_TYPE_GEMINI_LINK item.Link = template.URL(resolveURI(linkMatch[1], baseURL)) item.Text = linkMatch[2] if item.Text == "" { item.Text = linkMatch[1] } } items = append(items, item) } return } func DefaultHandler(tpl *template.Template, startpagetext string, assetList AssetList) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { if err := tpl.Execute(w, struct { Title string URI string Assets AssetList RawText string Lines []Item Error bool Protocol string }{"Gopher/Gemini proxy", "", assetList, startpagetext, nil, false, "startpage"}); err != nil { log.Println("Template error: " + err.Error()) } } } // GopherHandler returns a Handler that proxies requests // to the specified Gopher server as denoated by the first argument // to the request path and renders the content using the provided template. // The optional robots parameters points to a robotstxt.RobotsData struct // to test user agents against a configurable robotst.txt file. func GopherHandler(tpl *template.Template, robotsdata *robotstxt.RobotsData, assetList AssetList, robotsdebug bool) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { agent := req.UserAgent() path := strings.TrimPrefix(req.URL.Path, "/gopher/") if robotsdata != nil && robotsdebug && !robotsdata.TestAgent(path, agent) { log.Printf("UserAgent %s ignored robots.txt", agent) } parts := strings.Split(path, "/") hostport := parts[0] if len(hostport) == 0 { http.Redirect(w, req, "/", http.StatusFound) return } title := hostport var qs string if req.URL.RawQuery != "" { qs = fmt.Sprintf("?%s", url.QueryEscape(req.URL.RawQuery)) } uri, err := url.QueryUnescape(strings.Join(parts[1:], "/")) if err != nil { if e := tpl.Execute(w, struct { Title string URI string Assets AssetList RawText string Lines []Item Error bool Protocol string }{title, hostport, assetList, fmt.Sprintf("Error: %s", err), nil, true, "gopher"}); e != nil { log.Println("Template error: " + e.Error()) log.Println(err.Error()) } return } if uri != "" { title = fmt.Sprintf("%s/%s", hostport, uri) } res, err := gopher.Get( fmt.Sprintf( "gopher://%s/%s%s", hostport, uri, qs, ), ) if err != nil { if e := tpl.Execute(w, struct { Title string URI string Assets AssetList RawText string Lines []Item Error bool Protocol string }{title, fmt.Sprintf("%s/%s", hostport, uri), assetList, fmt.Sprintf("Error: %s", err), nil, true, "gopher"}); e != nil { log.Println("Template error: " + e.Error()) } return } if res.Body != nil { if len(parts) < 2 { io.Copy(w, res.Body) } else if strings.HasPrefix(parts[1], "0") && !strings.HasSuffix(uri, ".xml") && !strings.HasSuffix(uri, ".asc") { buf := new(bytes.Buffer) buf.ReadFrom(res.Body) if err := tpl.Execute(w, struct { Title string URI string Assets AssetList RawText string Lines []Item Error bool Protocol string }{title, fmt.Sprintf("%s/%s", hostport, uri), assetList, buf.String(), nil, false, "gopher"}); err != nil { log.Println("Template error: " + err.Error()) } } else if strings.HasPrefix(parts[1], "T") { _, _, err = vips.NewTransform(). Load(res.Body). ResizeStrategy(vips.ResizeStrategyAuto). ResizeWidth(160). Quality(75). Output(w). Apply() } else { io.Copy(w, res.Body) } } else { if err := renderGopherDirectory(w, tpl, assetList, uri, hostport, res.Dir); err != nil { if e := tpl.Execute(w, struct { Title string URI string Assets AssetList RawText string Lines []Item Error bool Protocol string }{title, fmt.Sprintf("%s/%s", hostport, uri), assetList, fmt.Sprintf("Error: %s", err), nil, true, "gopher"}); e != nil { log.Println("Template error: " + e.Error()) log.Println(e.Error()) } } } } } func GeminiHandler(tpl *template.Template, robotsdata *robotstxt.RobotsData, assetList AssetList, robotsdebug bool) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { agent := req.UserAgent() path := strings.TrimPrefix(req.URL.Path, "/gemini/") if robotsdata != nil && robotsdebug && !robotsdata.TestAgent(path, agent) { log.Printf("UserAgent %s ignored robots.txt", agent) } parts := strings.Split(path, "/") hostport := parts[0] if len(hostport) == 0 { http.Redirect(w, req, "/", http.StatusFound) return } title := hostport var qs string if req.URL.RawQuery != "" { qs = fmt.Sprintf("?%s", url.QueryEscape(req.URL.RawQuery)) } uri, err := url.QueryUnescape(strings.Join(parts[1:], "/")) if err != nil { if e := tpl.Execute(w, struct { Title string URI string Assets AssetList RawText string Lines []Item Error bool Protocol string }{title, hostport, assetList, fmt.Sprintf("Error: %s", err), nil, true, "gemini"}); e != nil { log.Println("Template error: " + e.Error()) log.Println(err.Error()) } return } if uri != "" { title = fmt.Sprintf("%s/%s", hostport, uri) } res, err := GeminiGet( fmt.Sprintf( "gemini://%s/%s%s", hostport, uri, qs, ), ) if err != nil { if e := tpl.Execute(w, struct { Title string URI string Assets AssetList RawText string Lines []Item Error bool Protocol string }{title, fmt.Sprintf("%s/%s", hostport, uri), assetList, fmt.Sprintf("Error: %s", err), nil, true, "gemini"}); e != nil { log.Println("Template error: " + e.Error()) log.Println(err.Error()) } return } if int(res.Header.Status/10) == 3 { baseURL, err := url.Parse(fmt.Sprintf( "gemini://%s/%s", hostport, uri, )) if err != nil { if e := tpl.Execute(w, struct { Title string URI string Assets AssetList RawText string Lines []Item Error bool Protocol string }{title, fmt.Sprintf("%s/%s", hostport, uri), assetList, fmt.Sprintf("Error: %s", err), nil, true, "gemini"}); e != nil { log.Println("Template error: " + e.Error()) log.Println(err.Error()) } return } http.Redirect(w, req, resolveURI(res.Header.Meta, baseURL), http.StatusFound) return } if int(res.Header.Status/10) != 2 { if err := tpl.Execute(w, struct { Title string URI string Assets AssetList RawText string Lines []Item Error bool Protocol string }{title, fmt.Sprintf("%s/%s", hostport, uri), assetList, fmt.Sprintf("Error %d: %s", res.Header.Status, res.Header.Meta), nil, true, "gemini"}); err != nil { log.Println("Template error: " + err.Error()) } return } if strings.HasPrefix(res.Header.Meta, "text/") { buf := new(bytes.Buffer) _, params, err := mime.ParseMediaType(res.Header.Meta) if err != nil { buf.ReadFrom(res.Body) } else { encoding, _ := charset.Lookup(params["charset"]) readbuf := new(bytes.Buffer) readbuf.ReadFrom(res.Body) writer := transform.NewWriter(buf, encoding.NewDecoder()) writer.Write(readbuf.Bytes()) writer.Close() } var ( rawText string items []Item ) if strings.HasPrefix(res.Header.Meta, MIME_GEMINI) { items = parseGeminiDocument(buf, uri, hostport) } else { rawText = buf.String() } if err := tpl.Execute(w, struct { Title string URI string Assets AssetList RawText string Lines []Item Error bool Protocol string }{title, fmt.Sprintf("%s/%s", hostport, uri), assetList, rawText, items, false, "gemini"}); err != nil { log.Println("Template error: " + err.Error()) } } else { io.Copy(w, res.Body) } } } // RobotsTxtHandler returns the contents of the robots.txt file // if configured and valid. func RobotsTxtHandler(robotstxtdata []byte) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { if robotstxtdata == nil { http.Error(w, "Not Found", http.StatusNotFound) return } w.Header().Set("Content-Type", "text/plain") w.Write(robotstxtdata) } } func FaviconHandler(favicondata []byte) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { if favicondata == nil { http.Error(w, "Not Found", http.StatusNotFound) return } w.Header().Set("Content-Type", "image/vnd.microsoft.icon") w.Header().Set("Cache-Control", "max-age=2592000") w.Write(favicondata) } } func StyleHandler(styledata []byte) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "text/css") w.Header().Set("Cache-Control", "max-age=2592000") w.Write(styledata) } } func JavaScriptHandler(jsdata []byte) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { w.Header().Set("Content-Type", "text/javascript") w.Header().Set("Cache-Control", "max-age=2592000") w.Write(jsdata) } } func FontHandler(woff2 bool, fontdata []byte) http.HandlerFunc { return func(w http.ResponseWriter, req *http.Request) { if fontdata == nil { http.Error(w, "Not Found", http.StatusNotFound) return } if woff2 { w.Header().Set("Content-Type", "font/woff2") } else { w.Header().Set("Content-Type", "font/woff") } w.Header().Set("Cache-Control", "max-age=2592000") w.Write(fontdata) } } // ListenAndServe creates a listening HTTP server bound to // the interface specified by bind and sets up a Gopher to HTTP // proxy proxying requests as requested and by default will prozy // to a Gopher server address specified by uri if no servers is // specified by the request. The robots argument is a pointer to // a robotstxt.RobotsData struct for testing user agents against // a configurable robots.txt file. func ListenAndServe(bind, startpagefile string, robotsfile string, robotsdebug bool, vipsconcurrency int) error { var ( tpl *template.Template robotsdata *robotstxt.RobotsData ) robotstxtdata, err := ioutil.ReadFile(robotsfile) if err != nil { log.Printf("error reading robots.txt: %s", err) robotstxtdata = nil } else { robotsdata, err = robotstxt.FromBytes(robotstxtdata) if err != nil { log.Printf("error reading robots.txt: %s", err) robotstxtdata = nil } } box := packr.New("assets", "./assets") fontdataw, err := box.Find("iosevka-term-ss03-regular.woff") if err != nil { fontdataw = []byte{} } fontwAsset := fmt.Sprintf("/iosevka-term-ss03-regular-%x.woff", md5.Sum(fontdataw)) fontdataw2, err := box.Find("iosevka-term-ss03-regular.woff2") if err != nil { fontdataw2 = []byte{} } fontw2Asset := fmt.Sprintf("/iosevka-term-ss03-regular-%x.woff2", md5.Sum(fontdataw2)) propfontdataw, err := box.Find("iosevka-aile-regular.woff") if err != nil { propfontdataw = []byte{} } propfontwAsset := fmt.Sprintf("/iosevka-aile-regular-%x.woff", md5.Sum(propfontdataw)) propfontdataw2, err := box.Find("iosevka-aile-regular.woff2") if err != nil { propfontdataw2 = []byte{} } propfontw2Asset := fmt.Sprintf("/iosevka-aile-regular-%x.woff2", md5.Sum(propfontdataw2)) styledata, err := box.Find("style.css") if err != nil { styledata = []byte{} } styleAsset := fmt.Sprintf("/style-%x.css", md5.Sum(styledata)) jsdata, err := box.Find("main.js") if err != nil { jsdata = []byte{} } jsAsset := fmt.Sprintf("/main-%x.js", md5.Sum(jsdata)) favicondata, err := box.Find("favicon.ico") if err != nil { favicondata = []byte{} } startpagedata, err := ioutil.ReadFile(startpagefile) if err != nil { startpagedata, err = box.Find("startpage.txt") if err != nil { startpagedata = []byte{} } } startpagetext := string(startpagedata) tpldata, err := ioutil.ReadFile(".template") if err == nil { tpltext = string(tpldata) } funcMap := template.FuncMap{ "safeHtml": func(s string) template.HTML { return template.HTML(s) }, "safeCss": func(s string) template.CSS { return template.CSS(s) }, "safeJs": func(s string) template.JS { return template.JS(s) }, "HTMLEscape": func(s string) string { return html.EscapeString(s) }, "split": strings.Split, "last": func(s []string) string { return s[len(s)-1] }, "pop": func(s []string) []string { return s[:len(s)-1] }, "replace": func(pattern, output string, input interface{}) string { var re = regexp.MustCompile(pattern) var inputStr = fmt.Sprintf("%v", input) return re.ReplaceAllString(inputStr, output) }, "trimLeftChar": func(s string) string { for i := range s { if i > 0 { return s[i:] } } return s[:0] }, "hasPrefix": func(s string, prefix string) bool { return strings.HasPrefix(s, prefix) }, "title": func(s string) string { return strings.Title(s) }, } tpl, err = template.New("gophermenu").Funcs(funcMap).Parse(tpltext) if err != nil { log.Fatal(err) } vips.Startup(&vips.Config{ ConcurrencyLevel: vipsconcurrency, }) assets := AssetList{styleAsset, jsAsset, fontwAsset, fontw2Asset, propfontwAsset, propfontw2Asset} http.Handle("/", gziphandler.GzipHandler(DefaultHandler(tpl, startpagetext, assets))) http.Handle("/gopher/", gziphandler.GzipHandler(GopherHandler(tpl, robotsdata, assets, robotsdebug))) http.Handle("/gemini/", gziphandler.GzipHandler(GeminiHandler(tpl, robotsdata, assets, robotsdebug))) http.Handle("/robots.txt", gziphandler.GzipHandler(RobotsTxtHandler(robotstxtdata))) http.Handle("/favicon.ico", gziphandler.GzipHandler(FaviconHandler(favicondata))) http.Handle(styleAsset, gziphandler.GzipHandler(StyleHandler(styledata))) http.Handle(jsAsset, gziphandler.GzipHandler(JavaScriptHandler(jsdata))) http.HandleFunc(fontwAsset, FontHandler(false, fontdataw)) http.HandleFunc(fontw2Asset, FontHandler(true, fontdataw2)) http.HandleFunc(propfontwAsset, FontHandler(false, propfontdataw)) http.HandleFunc(propfontw2Asset, FontHandler(true, propfontdataw2)) //http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.Dir("assets/")))) return http.ListenAndServe(bind, nil) }