304 lines
7.5 KiB
Go
304 lines
7.5 KiB
Go
package saltyim
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/keys-pub/keys"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
var (
|
|
_ Addr = (*addr)(nil)
|
|
_ Lookuper = (*DirectLookup)(nil)
|
|
_ Lookuper = (*ProxyLookup)(nil)
|
|
)
|
|
|
|
// Lookuper defines an interface for looking up Salty Addresses, primarily used by the PWA
|
|
// and possibly other clients, as a way to either perform direct lookups or to have lookups
|
|
// proxied through a broker.
|
|
type Lookuper interface {
|
|
LookupAddr(user string) (Addr, error)
|
|
}
|
|
|
|
func fetchConfig(addr string) (Config, Capabilities, error) {
|
|
log.Debugf("Fetching Well-Known Config: GET %s", addr)
|
|
res, err := Request(http.MethodGet, addr, nil, nil)
|
|
if err != nil {
|
|
return Config{}, Capabilities{}, err
|
|
}
|
|
|
|
var config Config
|
|
if err := json.NewDecoder(res.Body).Decode(&config); err != nil {
|
|
return Config{}, Capabilities{}, err
|
|
}
|
|
log.Debug(res.Header)
|
|
cap := Capabilities{
|
|
AcceptEncoding: res.Header.Get("Accept-Encoding"),
|
|
}
|
|
|
|
return config, cap, err
|
|
}
|
|
|
|
// Config represents a Salty Config for a User which at a minimum is required
|
|
// to have an Endpoint and Key (Public Key)
|
|
type Config struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Key string `json:"key"`
|
|
}
|
|
|
|
// Capabilities defines optional capabilities of a client
|
|
type Capabilities struct {
|
|
AcceptEncoding string
|
|
}
|
|
|
|
// String implements the fmt.Stringer interface and formats capabilities as HTTP headers
|
|
func (c Capabilities) String() string {
|
|
return fmt.Sprint("accept-encoding: ", c.AcceptEncoding)
|
|
}
|
|
|
|
type addr struct {
|
|
user string
|
|
domain string
|
|
discoveredDomain string
|
|
endpoint *url.URL
|
|
key *keys.EdX25519PublicKey
|
|
avatar string
|
|
capabilities Capabilities
|
|
checkedAvatar bool
|
|
}
|
|
|
|
func (a *addr) MarshalJSON() ([]byte, error) {
|
|
return json.Marshal(struct {
|
|
Addr string
|
|
User string
|
|
Domain string
|
|
Key string
|
|
Endpoint string
|
|
Avatar string
|
|
}{
|
|
User: a.user,
|
|
Domain: a.domain,
|
|
Addr: a.String(),
|
|
Key: a.key.ID().String(),
|
|
Endpoint: a.Endpoint().String(),
|
|
Avatar: a.Avatar(),
|
|
})
|
|
}
|
|
|
|
func (a *addr) UnmarshalJSON(data []byte) error {
|
|
res := struct {
|
|
Addr string
|
|
User string
|
|
Domain string
|
|
Key string
|
|
Endpoint string
|
|
Avatar string
|
|
}{}
|
|
|
|
if err := json.Unmarshal(data, &res); err != nil {
|
|
return err
|
|
}
|
|
|
|
a.user = res.User
|
|
a.domain = res.Domain
|
|
|
|
u, err := url.Parse(res.Endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("error parsing endpoint %q: %w", res.Endpoint, err)
|
|
}
|
|
a.endpoint = u
|
|
|
|
key, err := keys.NewEdX25519PublicKeyFromID(keys.ID(res.Key))
|
|
if err != nil {
|
|
return fmt.Errorf("error parsing public key %q: %w", res.Key, err)
|
|
}
|
|
a.key = key
|
|
a.avatar = res.Avatar
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *addr) String() string {
|
|
return fmt.Sprintf("%s@%s", a.user, a.domain)
|
|
}
|
|
|
|
func (a *addr) IsZero() bool {
|
|
return a.user == "" && a.domain == ""
|
|
}
|
|
func (a *addr) User() string { return a.user }
|
|
func (a *addr) Domain() string { return a.domain }
|
|
|
|
func (a *addr) Hash() string {
|
|
return fmt.Sprintf("%x", sha256.Sum256([]byte(strings.ToLower(a.String()))))
|
|
}
|
|
|
|
func (a *addr) Formatted() string {
|
|
return fmt.Sprintf("(%s)", a)
|
|
}
|
|
|
|
func (a *addr) Key() *keys.EdX25519PublicKey {
|
|
return a.key
|
|
}
|
|
|
|
func (a *addr) Endpoint() *url.URL {
|
|
return a.endpoint
|
|
}
|
|
|
|
func (a *addr) Cap() Capabilities {
|
|
return a.capabilities
|
|
}
|
|
|
|
func (a *addr) DiscoveredDomain() string {
|
|
if a.discoveredDomain != "" {
|
|
return a.discoveredDomain
|
|
}
|
|
return a.domain
|
|
}
|
|
|
|
func (a *addr) URI() string {
|
|
return fmt.Sprintf("https://%s/.well-known/salty/%s.json", a.discoveredDomain, a.user)
|
|
}
|
|
|
|
func (a *addr) HashURI() string {
|
|
return fmt.Sprintf("https://%s/.well-known/salty/%s.json", a.DiscoveredDomain(), a.Hash())
|
|
}
|
|
|
|
func (a *addr) Refresh() error {
|
|
log.Debugf("Looking up SRV record for _salty._tcp.%s", a.domain)
|
|
if target, err := DefaultResolver.LookupSRV("salty", "tcp", a.domain); err == nil {
|
|
a.discoveredDomain = target
|
|
log.Debugf("Discovered salty services %s", a.discoveredDomain)
|
|
} else if err != nil {
|
|
log.Debugf("error looking up SRV record for _salty._tcp.%s : %s", a.Domain(), err)
|
|
}
|
|
|
|
config, cap, err := fetchConfig(a.HashURI())
|
|
if err != nil {
|
|
// Fallback to plain user nick
|
|
config, cap, err = fetchConfig(a.URI())
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("error looking up user %s: %w", a, err)
|
|
}
|
|
key, err := keys.NewEdX25519PublicKeyFromID(keys.ID(config.Key))
|
|
if err != nil {
|
|
return fmt.Errorf("error parsing public key %s: %w", config.Key, err)
|
|
}
|
|
a.key = key
|
|
|
|
u, err := url.Parse(config.Endpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("error parsing endpoint %s: %w", config.Endpoint, err)
|
|
}
|
|
a.endpoint = u
|
|
a.capabilities = cap
|
|
|
|
log.Debugf("Discovered endpoint: %v", a.endpoint)
|
|
log.Debugf("Discovered capability: %v", a.capabilities)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *addr) Avatar() string {
|
|
if a.checkedAvatar {
|
|
return a.avatar
|
|
}
|
|
|
|
log.Debugf("Looking up SRV record for _avatars._tcp.%s", a.domain)
|
|
if target, err := DefaultResolver.LookupSRV("avatars", "tcp", a.domain); err == nil {
|
|
a.avatar = fmt.Sprintf("https://%s/avatar/%s", target, a.Hash())
|
|
}
|
|
a.checkedAvatar = true
|
|
|
|
return a.avatar
|
|
}
|
|
|
|
func (a *addr) WithEndpoint(endpoint *url.URL) Addr {
|
|
return &addr{
|
|
user: a.user,
|
|
domain: a.domain,
|
|
discoveredDomain: a.discoveredDomain,
|
|
endpoint: endpoint,
|
|
key: a.key,
|
|
avatar: a.avatar,
|
|
capabilities: a.capabilities,
|
|
checkedAvatar: a.checkedAvatar,
|
|
}
|
|
}
|
|
|
|
// ParseAddr parsers a Salty Address for a user into it's user and domain
|
|
// parts and returns an Addr object with the User and Domain and a method
|
|
// for returning the expected User's Well-Known URI
|
|
func ParseAddr(user string) (Addr, error) {
|
|
parts := strings.Split(strings.ToLower(user), "@")
|
|
if len(parts) != 2 {
|
|
return nil, fmt.Errorf("expected nick@domain found %q", user)
|
|
}
|
|
|
|
return &addr{user: parts[0], domain: parts[1]}, nil
|
|
}
|
|
|
|
// LookupAddr looks up a Salty Address for a User by parsing the user's domain and
|
|
// making a request to the user's Well-Known URI expected to be located at
|
|
// https://domain/.well-known/salty/<user>.json
|
|
func LookupAddr(addr string) (Addr, error) {
|
|
a, err := ParseAddr(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := a.Refresh(); err != nil {
|
|
return nil, err
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// DirectLookup performs a direct lookup request
|
|
type DirectLookup struct{}
|
|
|
|
// LookupAddr performs a lookup of a Salty Addr directly
|
|
func (l *DirectLookup) LookupAddr(user string) (Addr, error) {
|
|
return LookupAddr(user)
|
|
}
|
|
|
|
// ProxyLookup proxies lookup requests through a Salty Broker's /api/v1/lookup endpoint
|
|
type ProxyLookup struct {
|
|
// LookupEndpoint is the uri of the lookup endpoint of a broker
|
|
LookupEndpoint string
|
|
}
|
|
|
|
// LookupAddr performs a lookup of a Salty Addr directly and if the request fails for
|
|
// whatever reason (usuaully due to Cross-Orogin-Resource-Sharing policies / CORS) it
|
|
// uses the Salty Broker the PWA was served from initially to perform the lookup by
|
|
// proxying the lookup through the broker. Why? CORS sucks.
|
|
func (l *ProxyLookup) LookupAddr(user string) (Addr, error) {
|
|
addr, err := LookupAddr(user)
|
|
if err == nil {
|
|
return addr, nil
|
|
}
|
|
|
|
// Fallback to proxying the lookup through the broker...
|
|
|
|
res, err := Request(http.MethodGet, l.LookupEndpoint+user, nil, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
data, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := json.Unmarshal(data, &addr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return addr, nil
|
|
}
|