222 lines
4.9 KiB
Go
222 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.sr.ht/~adnano/go-gemini"
|
|
"git.sr.ht/~adnano/go-gemini/tofu"
|
|
)
|
|
|
|
const TIMEOUT_NS time.Duration = time.Duration(60_000_000_000)
|
|
|
|
var (
|
|
hosts tofu.KnownHosts
|
|
hostsfile *tofu.HostWriter
|
|
)
|
|
|
|
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 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 <url>\n")
|
|
os.Exit(1)
|
|
}
|
|
if len(os.Args) != 2 {
|
|
fmt.Fprintf(os.Stderr, "Usage: %s <url>\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, cancel := context.WithTimeout(context.Background(), TIMEOUT_NS)
|
|
defer cancel()
|
|
resp, req, err := do(client, ctx, req, nil)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
mime, _, _ := strings.Cut(resp.Meta, ";")
|
|
if mime == "application/rss+xml" || mime == "application/rss" ||
|
|
mime == "application/atom+xml" || mime == "application/atom" ||
|
|
mime == "application/xml" || mime == "text/xml" {
|
|
out, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
fmt.Print(string(out))
|
|
} else if mime == "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))
|
|
} else {
|
|
log.Fatal("unsupported mime type: ", mime)
|
|
}
|
|
}
|