package main import ( "os" "fmt" "log" "sort" "time" "bufio" "errors" "context" "strconv" "strings" "net/url" "crypto/x509" "encoding/xml" "path/filepath" "git.sr.ht/~adnano/go-gemini" "git.sr.ht/~adnano/go-gemini/tofu" ) var ( hosts tofu.KnownHosts hostsfile *tofu.HostWriter predirs map[string]*url.URL perrors map[string]PError ) func xdgDataHome() string { if s, ok := os.LookupEnv("XDG_DATA_HOME"); ok { return s } return filepath.Join(os.Getenv("HOME"), ".local", "share") } func init() { path := filepath.Join(xdgDataHome(), "konbata", "known_hosts") err := hosts.Load(path) if err != nil { log.Fatal(err) } hostsfile, err = tofu.OpenHostsFile(path) if err != nil { log.Fatal(err) } err = populatePRedirs() if err != nil { log.Fatal(err) } err = populatePErrors() if err != nil { log.Fatal(err) } } func populatePRedirs() error { file, err := os.OpenFile(filepath.Join(xdgDataHome(), "konbata", "predirs"), os.O_RDONLY|os.O_CREATE, 0600) if err != nil { return err } defer file.Close() scanner := bufio.NewScanner(file) predirs = make(map[string]*url.URL) for scanner.Scan() { urls := strings.Split(scanner.Text(), " ") if len(urls) != 2 { continue } url0, err := url.Parse(urls[0]) if err != nil { continue } if url0.Scheme != "gemini" { continue } url1, err := url.Parse(urls[1]) if err != nil { continue } if url1.Scheme != "gemini" { continue } predirs[url0.String()] = url1 } return nil } func savePRedirs() error { file, err := os.OpenFile(filepath.Join(xdgDataHome(), "konbata", "predirs"), os.O_WRONLY|os.O_CREATE, 0600) if err != nil { return err } defer file.Close() err = file.Truncate(0) if err != nil { return err } writer := bufio.NewWriter(file) for url0, url1 := range predirs { _, err = writer.WriteString(url0) if err != nil { return err } _, err = writer.WriteRune(' ') if err != nil { return err } _, err = writer.WriteString(url1.String()) if err != nil { return err } _, err = writer.WriteRune('\n') if err != nil { return err } } return writer.Flush() } func populatePErrors() error { file, err := os.OpenFile(filepath.Join(xdgDataHome(), "konbata", "perrors"), os.O_RDONLY|os.O_CREATE, 0600) if err != nil { return err } defer file.Close() scanner := bufio.NewScanner(file) perrors = make(map[string]PError) unescaper := strings.NewReplacer("\\n", "\n", "\\\\", "\\") for scanner.Scan() { values := strings.SplitN(scanner.Text(), " ", 3) if len(values) != 3 { continue } url, err := url.Parse(values[0]) if err != nil { continue } if url.Scheme != "gemini" { continue } code, err := strconv.Atoi(values[1]) if err != nil { continue } perrors[url.String()] = PError{ Code: gemini.Status(code), Message: unescaper.Replace(values[2]), } } return nil } func savePErrors() error { file, err := os.OpenFile(filepath.Join(xdgDataHome(), "konbata", "perrors"), os.O_WRONLY|os.O_CREATE, 0600) if err != nil { return err } defer file.Close() err = file.Truncate(0) if err != nil { return err } writer := bufio.NewWriter(file) escaper := strings.NewReplacer("\\", "\\\\", "\n", "\\n") for url, perror := range perrors { _, err = writer.WriteString(url) if err != nil { return err } _, err = writer.WriteRune(' ') if err != nil { return err } _, err = writer.WriteString(strconv.Itoa(int(perror.Code))) if err != nil { return err } _, err = writer.WriteRune(' ') if err != nil { return err } _, err = escaper.WriteString(writer, perror.Message) if err != nil { return err } _, err = writer.WriteRune('\n') if err != nil { return err } } return writer.Flush() } func trustCertificate(hostname string, cert *x509.Certificate) error { host := tofu.NewHost(hostname, cert.Raw) knownHost, ok := hosts.Lookup(hostname) if ok { // Check fingerprint if knownHost.Fingerprint != host.Fingerprint { return errors.New("fingerprint does not match!") } return nil } hosts.Add(host) hostsfile.WriteHost(host) return nil } func do(client gemini.Client, ctx context.Context, req *gemini.Request, via []*gemini.Request) (*gemini.Response, *gemini.Request, error) { if target, exists := predirs[req.URL.String()]; exists { via = append(via, req) if len(via) > 5 { return nil, req, errors.New("too many redirects") } redirect := *req redirect.URL = target return do(client, ctx, &redirect, via) } if perror, exists := perrors[req.URL.String()]; exists { return nil, req, errors.New(fmt.Sprintf("%d %s", perror.Code, perror.Message)) } resp, err := client.Do(ctx, req) if err != nil { return resp, req, err } if resp.Status == gemini.StatusPermanentRedirect { target, err := url.Parse(resp.Meta) if err != nil { return resp, req, err } if target.Scheme != "gemini" && target.Scheme != "" { return resp, req, errors.New(fmt.Sprintf("tried to redirect to scheme %s", target.Scheme)) } target = req.URL.ResolveReference(target) predirs[req.URL.String()] = target err = savePRedirs() if err != nil { return resp, req, err } } if resp.Status.Class() == gemini.StatusPermanentFailure { perrors[req.URL.String()] = PError{ Code: resp.Status, Message: resp.Meta, } err = savePErrors() if err != nil { return resp, req, err } } if resp.Status.Class() == gemini.StatusRedirect { via = append(via, req) if len(via) > 5 { return resp, req, errors.New("too many redirects") } target, err := url.Parse(resp.Meta) if err != nil { return resp, req, err } if target.Scheme != "gemini" && target.Scheme != "" { return resp, req, errors.New(fmt.Sprintf("tried to redirect to scheme %s", target.Scheme)) } target = req.URL.ResolveReference(target) redirect := *req redirect.URL = target return do(client, ctx, &redirect, via) } if resp.Status.Class() != gemini.StatusSuccess { return resp, req, errors.New(fmt.Sprintf("%d %s", resp.Status, resp.Meta)) } return resp, req, err } func main() { if len(os.Args) == 0 { fmt.Fprintf(os.Stderr, "Usage: konbata \n") os.Exit(1) } if len(os.Args) != 2 { fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) os.Exit(1) } req, err := gemini.NewRequest(os.Args[1]) if err != nil { log.Fatal(err) } client := gemini.Client{ TrustCertificate: trustCertificate, } ctx := context.Background() resp, req, err := do(client, ctx, req, nil) if err != nil { log.Fatal(err) } defer resp.Body.Close() if resp.Meta != "text/gemini" && !strings.HasPrefix(resp.Meta, "text/gemini;") { log.Fatal("mime type is not text/gemini") } aw := AtomWriter{ Title: "", Items: nil, } gemini.ParseLines(resp.Body, aw.Handle) sort.Sort(ByTime(aw.Items)) feed := Feed{ XMLNS: "http://www.w3.org/2005/Atom", Link: FeedLink{ Href: req.URL.String(), }, Id: req.URL.String(), Entries: nil, } if aw.Title == "" { feed.Title = req.URL.String() } else { feed.Title = aw.Title } if len(aw.Items) == 0 { feed.Updated = time.Now().Format(time.RFC3339) } else { feed.Updated = aw.Items[len(aw.Items) - 1].Date.Format(time.RFC3339) } for i := len(aw.Items); i != 0; i-- { item := aw.Items[i - 1] link, err := req.URL.Parse(item.Link) if err != nil { log.Fatal(err) } feed.Entries = append(feed.Entries, FeedEntry{ Title: item.Title, Link: EntryLink{ Href: link.String(), Rel: "alternate", }, Id: link.String(), Updated: item.Date.Format(time.RFC3339), }) } out, err := xml.MarshalIndent(feed, "", " ") if err != nil { log.Fatal(err) } fmt.Println(string(out)) } type Feed struct { XMLName xml.Name `xml:"feed"` XMLNS string `xml:"xmlns,attr"` Title string `xml:"title"` Link FeedLink `xml:"link"` Updated string `xml:"updated"` Id string `xml:"id"` Entries []FeedEntry `xml:"entry"` } type FeedLink struct { XMLName xml.Name `xml:"link"` Href string `xml:"href,attr"` } type FeedEntry struct { XMLName xml.Name `xml:"entry"` Title string `xml:"title"` Link EntryLink `xml:"link"` Id string `xml:"id"` Updated string `xml:"updated"` } type EntryLink struct { XMLName xml.Name `xml:"link"` Href string `xml:"href,attr"` Rel string `xml:"rel,attr"` } type FeedItem struct { Date time.Time Title string Link string } type ByTime []FeedItem func (a ByTime) Len() int { return len(a) } func (a ByTime) Less(i, j int) bool { return a[i].Date.Before(a[j].Date) } func (a ByTime) Swap(i, j int) { a[i], a[j] = a[j], a[i] } type PError struct { Code gemini.Status Message string } type AtomWriter struct { Title string Items []FeedItem } func (a *AtomWriter) Handle(line gemini.Line) { switch line := line.(type) { case gemini.LineLink: runes := []rune(line.Name) t, err := time.Parse("2006-01-02", string(runes[:10])) if err == nil { a.Items = append(a.Items, FeedItem{ Date: t, Title: strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(string(runes[10:])), ":-")), Link: string(line.URL), }) } case gemini.LineHeading1: if a.Title == "" { a.Title = string(line) } } }