package main import ( "bufio" "crypto/tls" "encoding/pem" "errors" "fmt" "net/url" "os" "path/filepath" "strings" "syscall" "time" "github.com/BurntSushi/toml" ) type ConfigMain struct { DefaultScheme string `toml:"default_scheme"` SoftWrap int `toml:"soft_wrap"` DownloadFolder string `toml:"download_folder"` VimKeys bool `toml:"vim_keys"` Quiet bool `toml:"quiet"` Pager string `toml:"pager"` Timeout duration `toml:"duration"` SavedHistoryDepth int `toml:"saved_history_depth"` } type Config struct { ConfigMain `toml:"main"` Handlers map[string]string `toml:"handlers"` } type duration struct{ time.Duration } func (d *duration) UnmarshalText(text []byte) error { var err error d.Duration, err = time.ParseDuration(string(text)) return err } func getConfig() (*Config, error) { home := os.Getenv("HOME") path := os.Getenv("XDG_CONFIG_HOME") if path == "" { path = filepath.Join(home, ".config") } path = filepath.Join(path, "x-1", "config.toml") if err := ensurePath(path); err != nil { return nil, err } c := Config{ ConfigMain: ConfigMain{ VimKeys: true, DefaultScheme: "gemini", SoftWrap: 100, DownloadFolder: home, Quiet: false, Pager: "auto", Timeout: duration{ time.Duration(10 * time.Second), }, SavedHistoryDepth: 30, }, Handlers: map[string]string{}, } if _, err := toml.DecodeFile(path, &c); err != nil { return nil, err } if strings.HasPrefix(c.DownloadFolder, "~") { c.DownloadFolder = home + c.DownloadFolder[1:] } return &c, nil } func getMarks() (map[string]string, error) { path, err := marksFilePath() if err != nil { return nil, err } marks := make(map[string]string) f, err := os.Open(path) if err != nil { return nil, err } defer func() { _ = f.Close() }() rdr := bufio.NewScanner(f) for rdr.Scan() { line := rdr.Text() name, target, _ := strings.Cut(line, ":") marks[name] = target } if err := rdr.Err(); err != nil { return nil, err } return marks, nil } func saveMarks(marks map[string]string) error { path, err := marksFilePath() if err != nil { return err } f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { return err } defer func() { _ = f.Close() }() for name, target := range marks { _, err := fmt.Fprintf(f, "%s:%s\n", name, target) if err != nil { return err } } return nil } func marksFilePath() (string, error) { return dataFilePath("marks") } func getTours() (map[string]*Tour, error) { path, err := toursFilePath() if err != nil { return nil, err } tours := make(map[string]*Tour) var current *Tour var currentName string f, err := os.Open(path) if err != nil { return nil, err } defer func() { _ = f.Close() }() rdr := bufio.NewScanner(f) for rdr.Scan() { line := rdr.Text() if strings.HasSuffix(line, ":") { if currentName != "" { tours[currentName] = current } currentName = strings.TrimSuffix(line, ":") current = &Tour{} } else { u, err := url.Parse(line) if err != nil { return nil, err } current.Links = append(current.Links, u) } } if err := rdr.Err(); err != nil { return nil, err } if currentName != "" { tours[currentName] = current } return tours, nil } func saveTours(tours map[string]*Tour) error { path, err := toursFilePath() if err != nil { return err } f, err := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { return err } defer func() { _ = f.Close() }() for name, tour := range tours { if len(tour.Links) == 0 { continue } if _, err := fmt.Fprintf(f, "%s:\n", name); err != nil { return err } for _, link := range tour.Links { if _, err := fmt.Fprintf(f, "%s\n", link.String()); err != nil { return err } } } return nil } func toursFilePath() (string, error) { return dataFilePath("tours") } func getTofuStore() error { tofuFilePath, err := dataFilePath("tofu") if err != nil { return err } tofuStore = map[string]string{} f, err := os.Open(tofuFilePath) if err != nil { return err } defer func() { _ = f.Close() }() rdr := bufio.NewScanner(f) for rdr.Scan() { domain, certhash, _ := strings.Cut(rdr.Text(), ":") tofuStore[domain] = certhash } if err := rdr.Err(); err != nil { return err } return nil } func saveTofuStore(store map[string]string) error { tofuFilePath, err := dataFilePath("tofu") if err != nil { return err } f, err := os.OpenFile(tofuFilePath, os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { return err } defer func() { _ = f.Close() }() for domain, certhash := range store { if _, err := fmt.Fprintf(f, "%s:%s\n", domain, certhash); err != nil { return err } } return nil } func dataFilePath(filename string) (string, error) { home := os.Getenv("HOME") path := os.Getenv("XDG_DATA_HOME") if path == "" { path = filepath.Join(home, ".local", "share") } path = filepath.Join(path, "x-1", filename) if err := ensurePath(path); err != nil { return "", err } return path, nil } func ensurePath(fpath string) error { if _, err := os.Stat(fpath); errors.Is(err, syscall.ENOENT) { if err := os.MkdirAll(filepath.Dir(fpath), 0o700); err != nil { return err } f, err := os.OpenFile(fpath, os.O_RDWR|os.O_CREATE, 0o600) if err != nil { return err } _ = f.Close() } return nil } func getIdentities() (Identities, error) { idents := Identities{ ByName: map[string]*tls.Config{}, ByDomain: map[string]*tls.Config{}, ByFolder: map[string]*tls.Config{}, ByPage: map[string]*tls.Config{}, } manifest, err := dataFilePath("identities") if err != nil { return idents, err } f, err := os.Open(manifest) if err != nil { return idents, err } defer func() { _ = f.Close() }() var curident *tls.Config rdr := bufio.NewScanner(f) for rdr.Scan() { line := rdr.Text() if strings.HasPrefix(line, ":") { kind, location, _ := strings.Cut(line[1:], " ") switch kind { case "domain": idents.ByDomain[location] = curident case "folder": idents.ByFolder[location] = curident case "page": idents.ByPage[location] = curident } } else { name := strings.TrimSuffix(line, ":") curident, err = getIdentity(name) if err != nil { return idents, err } idents.ByName[name] = curident } } if err := rdr.Err(); err != nil { return idents, err } return idents, nil } func saveIdentities(idents Identities) error { manifest, err := dataFilePath("identities") if err != nil { return err } f, err := os.OpenFile(manifest, os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { return err } defer func() { _ = f.Close() }() for name, ident := range idents.ByName { if _, err := fmt.Fprintf(f, "%s:\n", name); err != nil { return err } for domain, id := range idents.ByDomain { if id != ident { continue } if _, err := fmt.Fprintf(f, ":domain %s\n", domain); err != nil { return err } } for folder, id := range idents.ByFolder { if id != ident { continue } if _, err := fmt.Fprintf(f, ":folder %s\n", folder); err != nil { return err } } for page, id := range idents.ByPage { if id != ident { continue } if _, err := fmt.Fprintf(f, ":page %s\n", page); err != nil { return err } } } return nil } func getIdentity(name string) (*tls.Config, error) { fpath, err := dataFilePath("ident/" + name) if err != nil { return nil, err } cert, err := tls.LoadX509KeyPair(fpath, fpath) if err != nil { return nil, err } return identityForCert(cert), nil } func saveIdentity(name string, privkeyDER, certDER []byte) (string, error) { fpath, err := dataFilePath("ident/" + name) if err != nil { return "", err } f, err := os.OpenFile(fpath, os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { return "", err } defer func() { _ = f.Close() }() if err := pem.Encode(f, &pem.Block{Type: "PRIVATE KEY", Bytes: privkeyDER}); err != nil { return "", err } if err := pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil { return "", err } return fpath, nil } func removeIdentity(name string) error { fpath, err := dataFilePath("ident/" + name) if err != nil { return err } return os.Remove(fpath) }