commit db05f5b473bee9cbb5c748f2f7425a572d40ce4b Author: blank X Date: Mon Aug 9 21:14:56 2021 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c27a2ac --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +konbata diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..211185a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 blank X + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9d32b4a --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module konbata + +go 1.16 + +require git.sr.ht/~adnano/go-gemini v0.2.2 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..654e195 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +git.sr.ht/~adnano/go-gemini v0.2.2 h1:p2owKzrQ1wTgvPS5CZCPYArQyNUL8ZgYOHHrTjH9sdI= +git.sr.ht/~adnano/go-gemini v0.2.2/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c7f61eb --- /dev/null +++ b/main.go @@ -0,0 +1,227 @@ +package main + +import ( + "os" + "fmt" + "log" + "sort" + "time" + "errors" + "context" + "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 +) + +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) + } +} + +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(req *gemini.Request, via []*gemini.Request) (*gemini.Response, *gemini.Request, error) { + client := gemini.Client{ + TrustCertificate: trustCertificate, + } + ctx := context.Background() + resp, err := client.Do(ctx, req) + 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(&redirect, via) + } + + 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) + } + + resp, req, err := do(req, nil) + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + + if resp.Status.Class() != gemini.StatusSuccess { + log.Fatalf("%d %s", resp.Status, resp.Meta) + } + 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[0].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, _ := xml.MarshalIndent(feed, "", " ") + 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 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) + } + } +}