diff --git a/app/webfinger/client.go b/app/webfinger/client.go new file mode 100644 index 0000000..22ff1cc --- /dev/null +++ b/app/webfinger/client.go @@ -0,0 +1,46 @@ +package webfinger + +import ( + "crypto/ed25519" + "encoding/base64" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/oklog/ulid/v2" +) + +var ( + defaultExpire = 30 * time.Minute + defaultIssuer = "sour.is/webfinger" + defaultAudience = "sour.is/webfinger" +) + +func NewSignedRequest(jrd *JRD, key ed25519.PrivateKey) (string, error) { + type claims struct { + PubKey string `json:"pub"` + *JRD + jwt.RegisteredClaims + } + pub := []byte(key.Public().(ed25519.PublicKey)) + + j := claims{ + PubKey: enc(pub), + JRD: jrd.CloneValues(), + RegisteredClaims: jwt.RegisteredClaims{ + ID: ulid.Make().String(), + Subject: jrd.Subject, + Audience: jwt.ClaimStrings{defaultAudience}, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(defaultExpire)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Issuer: defaultIssuer, + }, + } + j.JRD.Subject = "" // move subject into registered claims. + + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, &j) + return token.SignedString(key) +} + +func enc(b []byte) string { + return base64.RawURLEncoding.EncodeToString(b) +} diff --git a/app/webfinger/jrd.go b/app/webfinger/jrd.go index d2982bd..9c3cae3 100644 --- a/app/webfinger/jrd.go +++ b/app/webfinger/jrd.go @@ -32,6 +32,19 @@ type JRD struct { event.AggregateRoot `yaml:"-"` } +func (a *JRD) CloneValues() *JRD { + m := make(map[string]*string, len(a.Properties)) + for k,v := range a.Properties { + m[k] = v + } + return &JRD{ + Subject: a.Subject, + Aliases: append([]string{}, a.Aliases...), + Properties: m, + Links: append([]*Link{}, a.Links...), + } +} + var _ event.Aggregate = (*JRD)(nil) // Link is a link to a related resource. diff --git a/app/webfinger/webfinger.go b/app/webfinger/webfinger.go index 80ba478..53947a6 100644 --- a/app/webfinger/webfinger.go +++ b/app/webfinger/webfinger.go @@ -20,8 +20,9 @@ import ( ) type service struct { - es *ev.EventStore - self set.Set[string] + es *ev.EventStore + self set.Set[string] + cache func(string) bool } type Option interface { @@ -34,6 +35,12 @@ func (o WithHostnames) ApplyWebfinger(s *service) { s.self = set.New(o...) } +type WithCache func(string) bool + +func (o WithCache) ApplyWebfinger(s *service) { + s.cache = o +} + func New(ctx context.Context, es *ev.EventStore, opts ...Option) (*service, error) { ctx, span := lg.Span(ctx) defer span.End() @@ -91,10 +98,9 @@ func (s *service) ServeHTTP(w http.ResponseWriter, r *http.Request) { r.Body.Close() type claims struct { - Subject string `json:"sub"` - PubKey string `json:"pub"` + PubKey string `json:"pub"` *JRD - jwt.StandardClaims + jwt.RegisteredClaims } token, err := jwt.ParseWithClaims( @@ -106,8 +112,7 @@ func (s *service) ServeHTTP(w http.ResponseWriter, r *http.Request) { return nil, fmt.Errorf("wrong type of claim") } - c.JRD.Subject = c.Subject - c.StandardClaims.Subject = c.Subject + c.JRD.Subject = c.RegisteredClaims.Subject c.SetProperty(NSpubkey, &c.PubKey) @@ -134,7 +139,17 @@ func (s *service) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - a, err := ev.Upsert(ctx, s.es, StreamID(c.Subject), func(ctx context.Context, a *JRD) error { + if c.ID != "" && s.cache != nil { + if ok := s.cache(c.ID); ok { + w.WriteHeader(http.StatusAlreadyReported) + fmt.Fprint(w, http.StatusText(http.StatusAlreadyReported)) + span.AddEvent("already seen ID") + + return + } + } + + a, err := ev.Upsert(ctx, s.es, StreamID(c.JRD.Subject), func(ctx context.Context, a *JRD) error { var auth *JRD // does the target have a pubkey for self auth? @@ -211,9 +226,16 @@ func (s *service) ServeHTTP(w http.ResponseWriter, r *http.Request) { resource := r.URL.Query().Get("resource") rels := r.URL.Query()["rel"] - if u := Parse(resource); u != nil && !s.self.Has(u.URL.Hostname()) { + if resource == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + + host, _ := splitHostPort(r.Host) + + if u := Parse(resource); u != nil && !s.self.Has(host) { redirect := &url.URL{} - redirect.Scheme = "https" + redirect.Scheme = u.URL.Scheme redirect.Host = u.URL.Host redirect.RawQuery = r.URL.RawQuery redirect.Path = "/.well-known/webfinger" @@ -279,3 +301,31 @@ func dec(s string) ([]byte, error) { s = strings.TrimSpace(s) return base64.RawURLEncoding.DecodeString(s) } +func splitHostPort(hostPort string) (host, port string) { + host = hostPort + + colon := strings.LastIndexByte(host, ':') + if colon != -1 && validOptionalPort(host[colon:]) { + host, port = host[:colon], host[colon+1:] + } + + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = host[1 : len(host)-1] + } + + return +} +func validOptionalPort(port string) bool { + if port == "" { + return true + } + if port[0] != ':' { + return false + } + for _, b := range port[1:] { + if b < '0' || b > '9' { + return false + } + } + return true +} \ No newline at end of file diff --git a/cmd/ev/app.webfinger.go b/cmd/ev/app.webfinger.go index 236cdc8..6000ce0 100644 --- a/cmd/ev/app.webfinger.go +++ b/cmd/ev/app.webfinger.go @@ -4,7 +4,9 @@ import ( "context" "fmt" "strings" + "time" + "github.com/patrickmn/go-cache" "github.com/sour-is/ev" "github.com/sour-is/ev/app/webfinger" "github.com/sour-is/ev/internal/lg" @@ -13,6 +15,11 @@ import ( "github.com/sour-is/ev/pkg/slice" ) +var ( + defaultExpire = 3 * time.Minute + cleanupInterval = 10 * time.Minute +) + var _ = apps.Register(50, func(ctx context.Context, svc *service.Harness) error { ctx, span := lg.Span(ctx) defer span.End() @@ -23,12 +30,17 @@ var _ = apps.Register(50, func(ctx context.Context, svc *service.Harness) error return fmt.Errorf("*es.EventStore not found in services") } - wf, err := webfinger.New( - ctx, - eventstore, - webfinger.WithHostnames( - strings.Fields(env.Default("WEBFINGER_DOMAINS", "sour.is")), - )) + cache := cache.New(defaultExpire, cleanupInterval) + var withCache webfinger.WithCache = (func(s string) bool { + if _, ok := cache.Get(s); ok { + return true + } + cache.SetDefault(s, true) + return false + }) + var withHostnames webfinger.WithHostnames = strings.Fields(env.Default("WEBFINGER_DOMAINS", "sour.is")) + + wf, err := webfinger.New(ctx, eventstore, withCache, withHostnames) if err != nil { span.RecordError(err) return err diff --git a/cmd/ev/main.go b/cmd/ev/main.go index 5df79d0..6ff06dd 100644 --- a/cmd/ev/main.go +++ b/cmd/ev/main.go @@ -21,17 +21,21 @@ func main() { <-ctx.Done() defer cancel() // restore interrupt function }() - + if err := Run(ctx); err != nil { + log.Fatal(err) + os.Exit(1) + } +} +func Run(ctx context.Context) error { svc := &service.Harness{} - ctx, stop := lg.Init(ctx, appName) svc.OnStop(stop) svc.Add(lg.NewHTTP(ctx)) - svc.Setup(ctx, apps.Apps()...) // Run application if err := svc.Run(ctx, appName, version); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatal(err) + return err } + return nil } diff --git a/cmd/webfinger-cli/main.go b/cmd/webfinger-cli/main.go index d6a97dd..4753c56 100644 --- a/cmd/webfinger-cli/main.go +++ b/cmd/webfinger-cli/main.go @@ -14,10 +14,8 @@ import ( "os/signal" "path/filepath" "strings" - "time" "github.com/docopt/docopt-go" - "github.com/golang-jwt/jwt" "gopkg.in/yaml.v3" "github.com/sour-is/ev/app/webfinger" @@ -127,23 +125,14 @@ func run(opts opts) error { if err != nil { return err } - bkey := []byte(key.Public().(ed25519.PublicKey)) - token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{ - "sub": opts.Subject, - "subject": opts.Subject, - "pub": enc(bkey), - "exp": time.Now().Add(90 * time.Minute).Unix(), - "iat": time.Now().Unix(), - "aud": "webfinger", - "iss": "sour.is-webfingerCLI", - }) - aToken, err := token.SignedString(key) + jrd := &webfinger.JRD{Subject: opts.Subject} + token, err := webfinger.NewSignedRequest(jrd, key) if err != nil { return err } - body := strings.NewReader(aToken) + body := strings.NewReader(token) req, err := http.NewRequest(http.MethodDelete, url.String(), body) if err != nil { return err @@ -173,7 +162,6 @@ func run(opts opts) error { if err != nil { return err } - bkey := []byte(key.Public().(ed25519.PublicKey)) fmt.Fprintln(os.Stderr, opts.File) fp, err := os.Open(opts.File) @@ -182,40 +170,20 @@ func run(opts opts) error { } y := yaml.NewDecoder(fp) - type claims struct { - Subject string `json:"sub"` - PubKey string `json:"pub"` - *webfinger.JRD - jwt.StandardClaims - } - for err == nil { - j := claims{ - PubKey: enc(bkey), - JRD: &webfinger.JRD{}, - StandardClaims: jwt.StandardClaims{ - Audience: "sour.is-webfinger", - ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), - IssuedAt: time.Now().Unix(), - Issuer: "sour.is-webfingerCLI", - }, - } + jrd := &webfinger.JRD{} - err = y.Decode(j.JRD) + err = y.Decode(jrd) if err != nil { break } - j.Subject = j.JRD.Subject - j.StandardClaims.Subject = j.JRD.Subject - - token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, &j) - aToken, err := token.SignedString(key) + token, err := webfinger.NewSignedRequest(jrd, key) if err != nil { return err } - body := strings.NewReader(aToken) + body := strings.NewReader(token) req, err := http.NewRequest(http.MethodPut, url.String(), body) if err != nil { diff --git a/cmd/webfinger/app.webfinger.go b/cmd/webfinger/app.webfinger.go deleted file mode 100644 index a84cfba..0000000 --- a/cmd/webfinger/app.webfinger.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "context" - "fmt" - - "github.com/sour-is/ev" - "github.com/sour-is/ev/app/webfinger" - "github.com/sour-is/ev/internal/lg" - "github.com/sour-is/ev/pkg/service" - "github.com/sour-is/ev/pkg/slice" -) - -var _ = apps.Register(50, func(ctx context.Context, svc *service.Harness) error { - ctx, span := lg.Span(ctx) - defer span.End() - - span.AddEvent("Enable WebFinger") - eventstore, ok := slice.Find[*ev.EventStore](svc.Services...) - if !ok { - return fmt.Errorf("*es.EventStore not found in services") - } - - wf, err := webfinger.New(ctx, eventstore) - if err != nil { - span.RecordError(err) - return err - } - svc.Add(wf) - - return nil -}) diff --git a/cmd/webfinger/app.webfinger.go b/cmd/webfinger/app.webfinger.go new file mode 120000 index 0000000..eaba5d5 --- /dev/null +++ b/cmd/webfinger/app.webfinger.go @@ -0,0 +1 @@ +../ev/app.webfinger.go \ No newline at end of file diff --git a/cmd/webfinger/main.go b/cmd/webfinger/main.go deleted file mode 100644 index 5df79d0..0000000 --- a/cmd/webfinger/main.go +++ /dev/null @@ -1,37 +0,0 @@ -package main - -import ( - "context" - "errors" - "log" - "net/http" - "os" - "os/signal" - - "github.com/sour-is/ev/internal/lg" - "github.com/sour-is/ev/pkg/service" -) - -var apps service.Apps -var appName, version = service.AppName() - -func main() { - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) - go func() { - <-ctx.Done() - defer cancel() // restore interrupt function - }() - - svc := &service.Harness{} - - ctx, stop := lg.Init(ctx, appName) - svc.OnStop(stop) - svc.Add(lg.NewHTTP(ctx)) - - svc.Setup(ctx, apps.Apps()...) - - // Run application - if err := svc.Run(ctx, appName, version); err != nil && !errors.Is(err, http.ErrServerClosed) { - log.Fatal(err) - } -} diff --git a/cmd/webfinger/main.go b/cmd/webfinger/main.go new file mode 120000 index 0000000..7e61bac --- /dev/null +++ b/cmd/webfinger/main.go @@ -0,0 +1 @@ +../ev/main.go \ No newline at end of file diff --git a/cmd/webfinger/svc.es.go b/cmd/webfinger/svc.es.go deleted file mode 100644 index 00cec22..0000000 --- a/cmd/webfinger/svc.es.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "context" - - "github.com/sour-is/ev" - "github.com/sour-is/ev/internal/lg" - "github.com/sour-is/ev/pkg/env" - "github.com/sour-is/ev/pkg/es" - diskstore "github.com/sour-is/ev/pkg/es/driver/disk-store" - memstore "github.com/sour-is/ev/pkg/es/driver/mem-store" - "github.com/sour-is/ev/pkg/es/driver/projecter" - resolvelinks "github.com/sour-is/ev/pkg/es/driver/resolve-links" - "github.com/sour-is/ev/pkg/es/driver/streamer" - "github.com/sour-is/ev/pkg/es/event" - "github.com/sour-is/ev/pkg/service" - "go.uber.org/multierr" -) - -var _ = apps.Register(10, func(ctx context.Context, svc *service.Harness) error { - ctx, span := lg.Span(ctx) - defer span.End() - - // setup eventstore - err := multierr.Combine( - ev.Init(ctx), - event.Init(ctx), - diskstore.Init(ctx), - memstore.Init(ctx), - ) - if err != nil { - span.RecordError(err) - return err - } - - eventstore, err := ev.Open( - ctx, - env.Default("EV_DATA", "mem:"), - resolvelinks.New(), - streamer.New(ctx), - projecter.New( - ctx, - projecter.DefaultProjection, - ), - ) - if err != nil { - span.RecordError(err) - return err - } - svc.Add(eventstore, &es.EventStore{EventStore: eventstore}) - - return nil -}) diff --git a/cmd/webfinger/svc.es.go b/cmd/webfinger/svc.es.go new file mode 120000 index 0000000..6280e25 --- /dev/null +++ b/cmd/webfinger/svc.es.go @@ -0,0 +1 @@ +../ev/svc.es.go \ No newline at end of file diff --git a/cmd/webfinger/svc.http.go b/cmd/webfinger/svc.http.go deleted file mode 100644 index b350f99..0000000 --- a/cmd/webfinger/svc.http.go +++ /dev/null @@ -1,46 +0,0 @@ -package main - -import ( - "context" - "log" - "net/http" - "strings" - - "github.com/rs/cors" - "github.com/sour-is/ev/internal/lg" - "github.com/sour-is/ev/pkg/env" - "github.com/sour-is/ev/pkg/mux" - "github.com/sour-is/ev/pkg/service" - "github.com/sour-is/ev/pkg/slice" -) - -var _ = apps.Register(20, func(ctx context.Context, svc *service.Harness) error { - s := &http.Server{} - svc.Add(s) - - mux := mux.New() - s.Handler = cors.AllowAll().Handler(mux) - - // s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // log.Println(r.URL.Path) - // mux.ServeHTTP(w, r) - // }) - - s.Addr = env.Default("EV_HTTP", ":8080") - if strings.HasPrefix(s.Addr, ":") { - s.Addr = "[::]" + s.Addr - } - svc.OnStart(func(ctx context.Context) error { - _, span := lg.Span(ctx) - defer span.End() - - log.Print("Listen on ", s.Addr) - span.AddEvent("begin listen and serve on " + s.Addr) - - mux.Add(slice.FilterType[interface{ RegisterHTTP(*http.ServeMux) }](svc.Services...)...) - return s.ListenAndServe() - }) - svc.OnStop(s.Shutdown) - - return nil -}) diff --git a/cmd/webfinger/svc.http.go b/cmd/webfinger/svc.http.go new file mode 120000 index 0000000..3dc665f --- /dev/null +++ b/cmd/webfinger/svc.http.go @@ -0,0 +1 @@ +../ev/svc.http.go \ No newline at end of file diff --git a/cmd/webfinger/webfinger_e2e_test.go b/cmd/webfinger/webfinger_e2e_test.go new file mode 100644 index 0000000..f50324f --- /dev/null +++ b/cmd/webfinger/webfinger_e2e_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "testing" + + "github.com/matryer/is" + "github.com/sour-is/ev/app/webfinger" + "golang.org/x/sync/errgroup" +) + +func TestMain(m *testing.M) { + data, err := os.MkdirTemp("", "data*") + if err != nil { + fmt.Printf("error creating data dir: %s\n", err) + os.Exit(1) + } + defer os.RemoveAll(data) + + os.Setenv("EV_HTTP", "[::1]:61234") + os.Setenv("WEBFINGER_DOMAINS", "::1") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wg, ctx := errgroup.WithContext(ctx) + wg.Go(func() error { + // Run application + if err := Run(ctx); err != nil { + return err + } + return nil + }) + wg.Go(func() error { + m.Run() + cancel() + return nil + }) + + if err := wg.Wait(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} + +func TestGetHTTP(t *testing.T) { + is := is.New(t) + res, err := http.DefaultClient.Get("http://[::1]:61234/.well-known/webfinger") + is.NoErr(err) + is.Equal(res.StatusCode, http.StatusBadRequest) +} + +func TestCreateResource(t *testing.T) { + is := is.New(t) + + _, priv, err := ed25519.GenerateKey(nil) + is.NoErr(err) + + jrd := &webfinger.JRD{ + Subject: "me@sour.is", + Properties: map[string]*string{ + "foo": ptr("bar"), + }, + } + + // create + token, err := webfinger.NewSignedRequest(jrd, priv) + is.NoErr(err) + + req, err := http.NewRequest(http.MethodPut, "http://[::1]:61234/.well-known/webfinger", strings.NewReader(token)) + is.NoErr(err) + + res, err := http.DefaultClient.Do(req) + is.NoErr(err) + + is.Equal(res.StatusCode, http.StatusCreated) + + // repeat + req, err = http.NewRequest(http.MethodPut, "http://[::1]:61234/.well-known/webfinger", strings.NewReader(token)) + is.NoErr(err) + + res, err = http.DefaultClient.Do(req) + is.NoErr(err) + + is.Equal(res.StatusCode, http.StatusAlreadyReported) + + // fetch + req, err = http.NewRequest(http.MethodGet, "http://[::1]:61234/.well-known/webfinger?resource=me@sour.is", nil) + is.NoErr(err) + + res, err = http.DefaultClient.Do(req) + is.NoErr(err) + + is.Equal(res.StatusCode, http.StatusOK) + + resJRD := &webfinger.JRD{} + err = json.NewDecoder(res.Body).Decode(resJRD) + is.NoErr(err) + is.Equal(jrd.Subject, resJRD.Subject) +} + +func ptr[T any](t T) *T { return &t } diff --git a/go.mod b/go.mod index 9b58aaa..98ea40b 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,8 @@ require ( require github.com/tj/go-semver v1.0.0 +require github.com/patrickmn/go-cache v2.1.0+incompatible + require ( git.mills.io/prologic/msgbus v0.1.19 // indirect github.com/ScaleFT/sshkeys v0.0.0-20200327173127-6142f742bca5 // indirect @@ -101,7 +103,6 @@ require ( require ( github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 - github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt/v4 v4.4.3 github.com/keys-pub/keys v0.1.22 github.com/matryer/is v1.4.0 diff --git a/go.sum b/go.sum index cad5ae1..37f25fe 100644 --- a/go.sum +++ b/go.sum @@ -155,8 +155,6 @@ github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= -github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -328,6 +326,8 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.10.3 h1:gph6h/qe9GSUw1NhH1gp+qb+h8rXD8Cy60Z32Qw3ELA= github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= github.com/petermattis/goid v0.0.0-20220331194723-8ee3e6ded87a h1:VXRRto5GMJPNfB7MNbUVoFhtxwoYjBEsIt/NpWg42U0=