From 3fccb3ae5fcf161ba9ddb47ccc5de31eaa6a9969 Mon Sep 17 00:00:00 2001 From: James Mills Date: Sat, 2 Apr 2022 02:59:39 +0000 Subject: [PATCH] Add Avatar service and cli for updating avatar on a broker (#116) This PR also: - Tidies up the default options and config - Tidies up the service user code Co-authored-by: James Mills Reviewed-on: https://git.mills.io/saltyim/saltyim/pulls/116 --- .drone.yml | 1 - .gitignore | 2 + Corefile | 5 ++ Makefile | 21 +++----- client.go | 28 +++++++++++ cmd/salty-chat/avatar.go | 67 +++++++++++++++++++++++++ cmd/salty-chat/chat.go | 5 +- cmd/salty-chat/send.go | 5 +- cmd/saltyd/main.go | 22 ++++----- data/avatars/.gitkeep | 0 db.home.arpa | 14 ++++++ go.mod | 5 ++ go.sum | 11 +++++ internal/api.go | 27 +++++++++++ internal/config.go | 13 ++--- internal/handlers.go | 58 ++++++++++++++++++++++ internal/options.go | 49 ++++++++++--------- internal/server.go | 23 ++++++--- internal/tasks.go | 22 ++++++++- internal/utils.go | 102 ++++++++++++++++++++++++++++++++++++++- internal/web/app.wasm | 4 +- types.go | 23 +++++++++ 22 files changed, 440 insertions(+), 67 deletions(-) create mode 100644 Corefile create mode 100644 cmd/salty-chat/avatar.go create mode 100644 data/avatars/.gitkeep create mode 100644 db.home.arpa diff --git a/.drone.yml b/.drone.yml index bb7cbf3..68c99cd 100644 --- a/.drone.yml +++ b/.drone.yml @@ -6,7 +6,6 @@ steps: - name: build-and-test image: r.mills.io/prologic/golang-alpine:latest commands: - - make deps - make build - make test diff --git a/.gitignore b/.gitignore index 98ed488..ca5ffd2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,9 @@ /cmd/saltyd/saltyd /cmd/salty-chat/salty-chat +/data/*.db /data/*.key +/data/avatars /data/.well-known /echobot.sh diff --git a/Corefile b/Corefile new file mode 100644 index 0000000..fce6da9 --- /dev/null +++ b/Corefile @@ -0,0 +1,5 @@ +home.arpa:5300 { + log + errors + file db.home.arpa +} diff --git a/Makefile b/Makefile index 0164c03..6e89629 100644 --- a/Makefile +++ b/Makefile @@ -31,23 +31,18 @@ preflight: ## Run preflight checks to ensure you have the right build tools deps: ## Install any dependencies required @$(GOCMD) install github.com/jsha/minica@latest - @$(GOCMD) install git.mills.io/prologic/devdns@latest + @$(GOCMD) install github.com/coredns/coredns@latest -dev : DEBUG=1 -dev : build ## Build debug versions of the cli and server - @./salty-chat -v - @./saltyd -v - -certs: certs/minica-key.pem certs/minica.pem certs/salty.home.arpa/key.pem certs/salty.home.arpa/cert.pem +certs: certs/minica-key.pem certs/minica.pem certs/_.home.arpa/key.pem certs/_.home.arpa/cert.pem @/bin/sh -c 'cd certs && minica --domains salty.home.arpa' -pwa-dev : DEBUG=1 -pwa-dev : build certs ## Build debug version of saltyd and PWA +dev : DEBUG=1 +dev : certs ## Build debug version of salty-chat (CLI and TUI) and saltyd (Broker and PWA) + @CGO_ENABLED=1 $(GOCMD) build ./cmd/salty-chat/... @CGO_ENABLED=1 $(GOCMD) build -tags "embed" ./cmd/saltyd/... - @./saltyd -D -b :https -u https://salty.home.arpa \ - --tls --tls-key ./certs/salty.home.arpa/key.pem \ - --tls-cert ./certs/salty.home.arpa/cert.pem \ - --svc-user salty@salty.home.arpa + @./saltyd -D -b :https --tls \ + --tls-key ./certs/_.home.arpa/key.pem \ + --tls-cert ./certs/_.home.arpa/cert.pem cli: ## Build the salty-chat command-line client and tui @$(GOCMD) build -tags "netgo static_build" -installsuffix netgo \ diff --git a/client.go b/client.go index 26df26d..3d69767 100644 --- a/client.go +++ b/client.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "crypto/sha256" + "encoding/json" "errors" "fmt" + "net/http" "net/url" "os" "path" @@ -393,3 +395,29 @@ func (cli *Client) Register() error { return nil } + +// SetAvatar creates or updates an avatar for a user with a broker +func (cli *Client) SetAvatar(content []byte) error { + // TODO: Verify cli.Me().Domain has valid SRV records + + req := AvatarRequest{Addr: cli.Me(), Content: content} + data, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("error serializing avatar request: %w", err) + } + signed, err := salty.Sign(cli.key, data) + if err != nil { + return fmt.Errorf("error signing avatar request: %w", err) + } + + // TODO: Automatically work out the URI based on SRV lookups of the user's address + u := cli.Me().Endpoint() + u.Path = "/api/v1/avatar" + + _, err = Request(http.MethodPost, u.String(), nil, bytes.NewBuffer(signed)) + if err != nil { + return fmt.Errorf("error updating avatar: %w", err) + } + + return nil +} diff --git a/cmd/salty-chat/avatar.go b/cmd/salty-chat/avatar.go new file mode 100644 index 0000000..4d99ce1 --- /dev/null +++ b/cmd/salty-chat/avatar.go @@ -0,0 +1,67 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "go.mills.io/saltyim" +) + +var setavatarCmd = &cobra.Command{ + Use: "setavatar ", + Short: "Creates or updates your avatar with a Salty Broker", + Long: `This command creates or updates your avatar with a regisetered account +on a Salty Broker (an instance of saltyd). + +NOTE: This is only spported on a Salty Broker.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + user := viper.GetString("user") + identity := viper.GetString("identity") + + var profiles []profile + viper.UnmarshalKey("profiles", &profiles) + for _, p := range profiles { + if user == p.User { + identity = p.Identity + } + } + + me := &saltyim.Addr{} + if sp := strings.Split(user, "@"); len(sp) > 1 { + me.User = sp[0] + me.Domain = sp[1] + } + // XXX: What if me.IsZero() + + setavatar(me, identity, args[0]) + }, +} + +func init() { + rootCmd.AddCommand(setavatarCmd) +} + +func setavatar(me *saltyim.Addr, identity, fn string) { + cli, err := saltyim.NewClient(me, saltyim.WithIdentityPath(identity)) + if err != nil { + fmt.Fprintf(os.Stderr, "error initializing client: %s\n", err) + os.Exit(2) + } + + data, err := os.ReadFile(fn) + if err != nil { + fmt.Fprintf(os.Stderr, "error reading avatar file %s: %s", fn, err) + os.Exit(2) + } + + if err := cli.SetAvatar(data); err != nil { + fmt.Fprintf(os.Stderr, "error updating avatar: %s\n", err) + os.Exit(2) + } + + fmt.Println("Success! 🥳") +} diff --git a/cmd/salty-chat/chat.go b/cmd/salty-chat/chat.go index d3368ed..b29801d 100644 --- a/cmd/salty-chat/chat.go +++ b/cmd/salty-chat/chat.go @@ -13,8 +13,9 @@ import ( ) var chatCmd = &cobra.Command{ - Use: "chat ", - Short: "Creates a chat with a specific user", + Use: "chat ", + Aliases: []string{"talk"}, + Short: "Creates a chat with a specific user", Long: `This command creates a chat with the specified user by discovering and subscribing to your endpoint and prompts for input and sends encrypted messages to the user via their discovered endpoint.`, diff --git a/cmd/salty-chat/send.go b/cmd/salty-chat/send.go index a6071c3..ec23b0d 100644 --- a/cmd/salty-chat/send.go +++ b/cmd/salty-chat/send.go @@ -18,8 +18,9 @@ const ( ) var sendCmd = &cobra.Command{ - Use: "send []", - Short: "Send a message to a user", + Use: "send []", + Aliases: []string{"write", "post"}, + Short: "Send a message to a user", Long: `This command attempts to lookup the user's Salty Config by using the Salty IM Discovery process by making a request to the user's Well-Known URI After it will attempt to send the message via a HTTP POST to their Endpoint. diff --git a/cmd/saltyd/main.go b/cmd/saltyd/main.go index 798c6af..7f6281b 100644 --- a/cmd/saltyd/main.go +++ b/cmd/saltyd/main.go @@ -29,14 +29,14 @@ var ( tlsCert string // Basic options - data string - store string - baseURL string - svcUser string + data string + store string + baseURL string + primaryDomain string // Oeprator - adminUser string - adminEmail string + adminUser string + supportEmail string ) const ( @@ -68,11 +68,11 @@ func init() { flag.StringVarP(&data, "data", "d", internal.DefaultData, "data directory") flag.StringVarP(&store, "store", "s", internal.DefaultStore, "store to use") flag.StringVarP(&baseURL, "base-url", "u", internal.DefaultBaseURL, "base url to use") - flag.StringVarP(&svcUser, "svc-user", "", internal.DefaultSvcUser, "internal service user") + flag.StringVarP(&primaryDomain, "primary-domain", "p", internal.DefaultPrimaryDomain, "primary domain delegated to this broker") // Oeprator - flag.StringVarP(&adminUser, "admin-user", "A", internal.DefaultAdminUser, "default admin user to use") - flag.StringVarP(&adminEmail, "admin-email", "E", internal.DefaultAdminEmail, "default admin user email") + flag.StringVarP(&adminUser, "admin-user", "A", internal.DefaultAdminUser, "public key of admin user") + flag.StringVarP(&supportEmail, "support-email", "E", internal.DefaultSupportEmail, "support email") } func flagNameFromEnvironmentName(s string) string { @@ -127,11 +127,11 @@ func main() { internal.WithData(data), internal.WithStore(store), internal.WithBaseURL(baseURL), - internal.WithSvcUser(svcUser), + internal.WithPrimaryDomain(primaryDomain), // Oeprator internal.WithAdminUser(adminUser), - internal.WithAdminEmail(adminEmail), + internal.WithSupportEmail(supportEmail), ) if err != nil { log.WithError(err).Fatal("error creating server") diff --git a/data/avatars/.gitkeep b/data/avatars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/db.home.arpa b/db.home.arpa new file mode 100644 index 0000000..548852a --- /dev/null +++ b/db.home.arpa @@ -0,0 +1,14 @@ +$TTL 60 +$ORIGIN home.arpa. +@ IN SOA home.arpa. admin.home.arpa. ( + 2022040100 ; Serial + 4H ; Refresh + 1H ; Retry + 7D ; Expire + 4H ) ; Negative Cache TTL + + +@ IN A 127.0.0.1 +salty.home.arpa. IN A 127.0.0.1 +_salty._tcp.home.arpa. IN SRV 0 0 443 salty.home.arpa. +_avatars._tcp.home.arpa. IN SRV 0 0 443 salty.home.arpa. diff --git a/go.mod b/go.mod index 9e12e2a..1641ae4 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,15 @@ go 1.18 require ( github.com/andybalholm/brotli v1.0.4 github.com/avast/retry-go v2.7.0+incompatible + github.com/cyphar/filepath-securejoin v0.2.3 + github.com/disintegration/gift v1.2.1 + github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec + github.com/h2non/filetype v1.1.3 github.com/likexian/doh-go v0.6.4 github.com/mattn/go-isatty v0.0.14 github.com/mitchellh/go-homedir v1.1.0 github.com/mlctrez/goapp-mdc v0.2.6 + github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 github.com/oklog/ulid/v2 v2.0.2 github.com/sasha-s/go-deadlock v0.3.1 github.com/sirupsen/logrus v1.8.1 diff --git a/go.sum b/go.sum index a41765d..4eac617 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfc github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cyphar/filepath-securejoin v0.2.3 h1:YX6ebbZCZP7VkM3scTTokDgBL2TY741X51MTk3ycuNI= +github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7hqDjlFjiygg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -115,6 +117,11 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dim13/crc24 v0.0.0-20190831075008-15d874f06514 h1:65LapgsVGYE/ET3yLMmnprQlZjEjCLt68V+Paz+dq+g= github.com/dim13/crc24 v0.0.0-20190831075008-15d874f06514/go.mod h1:TejqRSRwQ36N6MSUaGN8WipY4g3bgf5HeEi9fApCxG0= +github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= +github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc= +github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= +github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg= +github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -253,6 +260,8 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmg github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= +github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -378,6 +387,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022 h1:Ys0rDzh8s4UMlGaDa1UTA0sfKgvF0hQZzTYX8ktjiDc= +github.com/nullrocks/identicon v0.0.0-20180626043057-7875f45b0022/go.mod h1:x4NsS+uc7ecH/Cbm9xKQ6XzmJM57rWTkjywjfB2yQ18= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/oklog/ulid/v2 v2.0.2 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc= github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68= diff --git a/internal/api.go b/internal/api.go index c1195e5..67aa2ef 100644 --- a/internal/api.go +++ b/internal/api.go @@ -32,8 +32,13 @@ func (a *API) initRoutes() { router := a.router.Group("/api/v1") router.GET("/ping", a.PingEndpoint()) + + // Lookup and Send support for Web / PWA clients router.GET("/lookup/:addr", a.LookupEndpoint()) router.POST("/send", a.SendEndpoint()) + + // Avatar Service + router.POST("/avatar", a.AvatarEndpoint()) } // PingEndpoint ... @@ -78,3 +83,25 @@ func (a *API) SendEndpoint() httprouter.Handle { http.Error(w, "Message Accepted", http.StatusAccepted) } } + +// AvatarEndpoint ... +func (a *API) AvatarEndpoint() httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + req, signer, err := saltyim.NewAvatarRequest(r.Body) + if err != nil { + log.WithError(err).Error("error parsing avatar request") + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + // TODO: Do something with signer? + log.Debugf("request signed by %s", signer) + + if err := CreateOrUpdateAvatar(a.config, req.Addr, req.Content); err != nil { + log.WithError(err).Errorf("error creating/updating avatar for %s", req.Addr) + http.Error(w, "Avatar Error", http.StatusInternalServerError) + return + } + + http.Error(w, "Avatar Created", http.StatusCreated) + } +} diff --git a/internal/config.go b/internal/config.go index bb987f2..63b4ac2 100644 --- a/internal/config.go +++ b/internal/config.go @@ -14,13 +14,14 @@ type Config struct { TLSKey string `json:"-"` TLSCert string `json:"-"` - Data string `json:"-"` - Store string `json:"-"` - BaseURL string - SvcUser string + Data string `json:"-"` + Store string `json:"-"` - AdminUser string `json:"-"` - AdminEmail string `json:"-"` + BaseURL string + PrimaryDomain string + + AdminUser string `json:"-"` + SupportEmail string `json:"-"` baseURL *url.URL } diff --git a/internal/handlers.go b/internal/handlers.go index ae9726b..f2db1eb 100644 --- a/internal/handlers.go +++ b/internal/handlers.go @@ -1,11 +1,18 @@ package internal import ( + "fmt" + "image/png" "net/http" + "os" "path/filepath" + "strings" + "time" "git.mills.io/prologic/useragent" + securejoin "github.com/cyphar/filepath-securejoin" "github.com/julienschmidt/httprouter" + log "github.com/sirupsen/logrus" ) func (s *Server) NotFoundHandler(w http.ResponseWriter, r *http.Request) { @@ -46,3 +53,54 @@ func (s *Server) InboxHandler() httprouter.Handle { s.bus.ServeHTTP(w, r) } } + +// AvatarHandler ... +func (s *Server) AvatarHandler() httprouter.Handle { + avatarsDir := filepath.Join(s.config.Data, avatarsPath) + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + w.Header().Set("Cache-Control", "public, no-cache, must-revalidate") + hash := p.ByName("hash") + if hash == "" { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + fn, err := securejoin.SecureJoin(avatarsDir, fmt.Sprintf("%s.png", hash)) + if err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + if fileInfo, err := os.Stat(fn); err == nil { + w.Header().Set("Etag", fmt.Sprintf("W/\"%s-%s\"", r.RequestURI, fileInfo.ModTime().Format(time.RFC3339))) + w.Header().Set("Last-Modified", fileInfo.ModTime().Format(http.TimeFormat)) + http.ServeFile(w, r, fn) + return + } + etag := fmt.Sprintf("W/\"%s\"", r.RequestURI) + if match := r.Header.Get("If-None-Match"); match != "" { + if strings.Contains(match, etag) { + w.WriteHeader(http.StatusNotModified) + return + } + } + w.Header().Set("Etag", etag) + if r.Method == http.MethodHead { + return + } + img, err := GenerateAvatar(s.config, hash) + if err != nil { + log.WithError(err).Errorf("error generating avatar for %s", hash) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if r.Method == http.MethodHead { + return + } + w.Header().Set("Content-Type", "image/png") + if err := png.Encode(w, img); err != nil { + log.WithError(err).Error("error encoding auto generated avatar") + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} diff --git a/internal/options.go b/internal/options.go index c8d28a9..e39e56a 100644 --- a/internal/options.go +++ b/internal/options.go @@ -7,6 +7,8 @@ import ( ) const ( + serviceUser = "salty" + // InvalidConfigValue is the constant value for invalid config values // which must be changed for production configurations before successful // startup @@ -28,28 +30,31 @@ const ( DefaultData = "./data" // DefaultStore is the default data store used for accounts, sessions, etc - DefaultStore = "bitcask://saltyim.db" + DefaultStore = "bitcask://data/saltyim.db" // DefaultBaseURL is the default Base URL for the server - DefaultBaseURL = "http://0.0.0.0:8000" + DefaultBaseURL = "https://salty." + DefaultPrimaryDomain - // DefaultSvcUser is the default user for internal service to handle registrations and other operations - DefaultSvcUser = "salty@localhost" + // DefaultPrimaryDomain is the default primary domain delegated to this broker + DefaultPrimaryDomain = "home.arpa" // DefaultAdminUser is the default publickye to grant admin privileges to DefaultAdminUser = "" - // DefaultAdminEmail is the default email of the admin user used in support requests - DefaultAdminEmail = "support@salty.im" + // DefaultSupportEmail is the default email of the admin user used in support requests + DefaultSupportEmail = "support@" + DefaultPrimaryDomain ) func NewConfig() *Config { return &Config{ - Debug: DefaultDebug, - Store: DefaultStore, - BaseURL: DefaultBaseURL, - AdminUser: DefaultAdminUser, - AdminEmail: DefaultAdminEmail, + Debug: DefaultDebug, + Store: DefaultStore, + + BaseURL: DefaultBaseURL, + PrimaryDomain: DefaultPrimaryDomain, + + AdminUser: DefaultAdminUser, + SupportEmail: DefaultSupportEmail, } } @@ -120,6 +125,14 @@ func WithBaseURL(baseURL string) Option { } } +// WithPrimaryDomain sets the Primary Domain this broker is delegated for +func WithPrimaryDomain(primaryDomain string) Option { + return func(cfg *Config) error { + cfg.PrimaryDomain = primaryDomain + return nil + } +} + // WithAdminUser sets the Admin user used for granting special features to func WithAdminUser(adminUser string) Option { return func(cfg *Config) error { @@ -128,18 +141,10 @@ func WithAdminUser(adminUser string) Option { } } -// WithAdminEmail sets the Admin email used to contact the pod operator -func WithAdminEmail(adminEmail string) Option { +// WithSupportEmail sets the Support email used to contact the operator of this broker +func WithSupportEmail(supportEmail string) Option { return func(cfg *Config) error { - cfg.AdminEmail = adminEmail - return nil - } -} - -// WithSvcUser sets the internal service user address -func WithSvcUser(user string) Option { - return func(c *Config) error { - c.SvcUser = user + cfg.SupportEmail = supportEmail return nil } } diff --git a/internal/server.go b/internal/server.go index 5725164..63c5ba6 100644 --- a/internal/server.go +++ b/internal/server.go @@ -149,7 +149,7 @@ func (s *Server) ListenAndServe() error { m := &autocert.Manager{ Cache: autocert.DirCache(filepath.Join(s.config.Data, acmeDir)), Prompt: autocert.AcceptTOS, - Email: s.config.AdminEmail, + Email: s.config.SupportEmail, HostPolicy: autocert.HostWhitelist(s.config.baseURL.Hostname()), } s.server.TLSConfig = m.TLSConfig() @@ -228,10 +228,12 @@ func (s *Server) setupCronJobs() error { } func (s *Server) setupServiceUser() error { - log.Infof("starting service user %s", s.config.SvcUser) + svcUser := fmt.Sprintf("%s@%s", serviceUser, s.config.PrimaryDomain) + + log.Infof("starting service user %s", svcUser) // create our addr - me, err := saltyim.ParseAddr(s.config.SvcUser) + me, err := saltyim.ParseAddr(svcUser) if err != nil { return err } @@ -288,6 +290,10 @@ func (s *Server) runStartupJobs() { } func (s *Server) initRoutes() { + // + // PWA + // + app := &app.Handler{ Name: "Salty Chat", ShortName: "Salty Chat", @@ -333,10 +339,15 @@ func (s *Server) initRoutes() { s.router.Handler(http.MethodGet, "/app-worker.js", app) s.router.Handler(http.MethodGet, "/manifest.webmanifest", app) + // Discovery s.router.GET("/.well-known/salty/:config", s.ConfigHandler()) + // Inbox s.router.GET("/inbox/:inbox", s.InboxHandler()) s.router.POST("/inbox/:inbox", s.InboxHandler()) + + // Avatar Service + s.router.GET("/avatar/:hash", s.AvatarHandler()) } // NewServer ... @@ -429,14 +440,14 @@ func NewServer(bind string, options ...Option) (*Server, error) { log.Info("succeessfully setup service user") server.setupMetrics() - log.Infof("serving metrics endpoint at http://%s/metrics", server.bind) + log.Infof("serving metrics endpoint at %s/metrics", server.config.BaseURL) // Log interesting configuration options log.Infof("Debug: %t", server.config.Debug) log.Infof("Base URL: %s", server.config.BaseURL) - log.Infof("Svc User; %s", server.config.SvcUser) + log.Infof("Primary Domain: %s", server.config.PrimaryDomain) log.Infof("Admin User: %s", server.config.AdminUser) - log.Infof("Admin Email: %s", server.config.AdminEmail) + log.Infof("Support Email: %s", server.config.SupportEmail) api.initRoutes() server.initRoutes() diff --git a/internal/tasks.go b/internal/tasks.go index 915e8c0..03bae9f 100644 --- a/internal/tasks.go +++ b/internal/tasks.go @@ -1,6 +1,7 @@ package internal import ( + "bytes" "encoding/json" "fmt" "os" @@ -10,7 +11,9 @@ import ( ) const ( - wellknownPath = ".well-known/salty" + wellknownPath = ".well-known/salty" + avatarsPath = "avatars" + avatarResolution = 80 // 80x80 px ) func CreateConfig(conf *Config, hash string, key string) error { @@ -50,3 +53,20 @@ func CreateConfig(conf *Config, hash string, key string) error { return nil } + +func CreateOrUpdateAvatar(conf *Config, addr *saltyim.Addr, contents []byte) error { + p := filepath.Join(conf.Data, avatarsPath) + p = saltyim.FixUnixHome(p) + + if err := os.MkdirAll(p, 0755); err != nil { + return fmt.Errorf("error creating avatars paths %s: %w", p, err) + } + + fn := filepath.Join(p, fmt.Sprintf("%s.png", addr.Hash())) + + if err := SaveAvatar(fn, bytes.NewBuffer(contents), avatarResolution); err != nil { + return fmt.Errorf("error writing avatar %s: %w", fn, err) + } + + return nil +} diff --git a/internal/utils.go b/internal/utils.go index 461f090..23ea2b0 100644 --- a/internal/utils.go +++ b/internal/utils.go @@ -1,6 +1,22 @@ package internal -import "os" +import ( + "crypto/rand" + "fmt" + "image" + "image/png" + "io" + "os" + + // Blank import so we can handle image/jpeg + _ "image/jpeg" + + "github.com/disintegration/gift" + "github.com/disintegration/imageorient" + "github.com/h2non/filetype" + "github.com/nullrocks/identicon" + log "github.com/sirupsen/logrus" +) // FileExists returns true if the given file exists func FileExists(name string) bool { @@ -11,3 +27,87 @@ func FileExists(name string) bool { } return true } + +// GenerateAvatar generates a unique avatar for a user based on an identicon +func GenerateAvatar(conf *Config, domainNick string) (image.Image, error) { + ig, err := identicon.New(conf.PrimaryDomain, 7, 4) + if err != nil { + log.WithError(err).Error("error creating identicon generator") + return nil, err + } + ii, err := ig.Draw(domainNick) + if err != nil { + log.WithError(err).Errorf("error generating external avatar for %s", domainNick) + return nil, err + } + return ii.Image(avatarResolution), nil +} + +// GenerateRandomToken generates random tokens used primarily for recovery +func GenerateRandomToken() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return fmt.Sprintf("%x", b) +} + +// IsImage returns true if the bytes are an image +func IsImage(data []byte) bool { + head := data[:261] + return filetype.IsImage(head) +} + +// ImageOptions set options for handling image resizing +type ImageOptions struct { + Resize bool + Width int + Height int +} + +// ProcessImage processes an image and resizes the image according to the +// image options provided and returns a new image for storage or to be served +func ProcessImage(r io.Reader, opts *ImageOptions) (image.Image, error) { + img, _, err := imageorient.Decode(r) + if err != nil { + log.WithError(err).Error("imageorient.Decode failed") + return nil, err + } + + if opts != nil && opts.Resize { + g := gift.New() + if opts.Width > 0 && opts.Height > 0 { + g.Add(gift.ResizeToFit(opts.Width, opts.Height, gift.LanczosResampling)) + } else if (opts.Width+opts.Height > 0) && (opts.Height > 0 || img.Bounds().Size().X > opts.Width) { + g.Add(gift.Resize(opts.Width, opts.Height, gift.LanczosResampling)) + } + + newImg := image.NewRGBA(g.Bounds(img.Bounds())) + + g.Draw(newImg, img) + + return newImg, nil + } + + return img, nil +} + +// SaverAvatar processes an avatar, processes it and resizes it to the given size +// saves it to storage with the given filename +func SaveAvatar(fn string, r io.Reader, size int) error { + opts := &ImageOptions{Resize: true, Height: size, Width: size} + img, err := ProcessImage(r, opts) + if err != nil { + return fmt.Errorf("error processing avatar: %w", err) + } + + f, err := os.OpenFile(fn, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("error opening file for writing: %w", err) + } + defer f.Close() + + if png.Encode(f, img); err != nil { + return fmt.Errorf("error encoding and writing avatar: %w", err) + } + + return nil +} diff --git a/internal/web/app.wasm b/internal/web/app.wasm index 8efa11d..df9a881 100755 --- a/internal/web/app.wasm +++ b/internal/web/app.wasm @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:36a4b38a1e641b50be9dcc9094dec0dbf623dbb6cb6d74597328921c4ab8261d -size 28479714 +oid sha256:715314606d8f94c1c3d1ea181008298cb970fec7a273eedaa11865dc28ca096e +size 28479770 diff --git a/types.go b/types.go index 4771356..f6c7622 100644 --- a/types.go +++ b/types.go @@ -54,3 +54,26 @@ func NewSendRequest(r io.Reader) (req SendRequest, signer string, err error) { err = json.Unmarshal(out, &req) return } + +// AvatarRequest is the request used by clients to send messages via a broker +type AvatarRequest struct { + Addr *Addr + Content []byte +} + +// NewAvatarRequest reads the signed request body from a client, verifies its signature +// and returns the resulting `AvatarRequest` and key used to sign the request on success +// otherwise an empty object and en error on failure. +func NewAvatarRequest(r io.Reader) (req AvatarRequest, signer string, err error) { + body, err := ioutil.ReadAll(r) + if err != nil { + return + } + out, key, err := salty.Verify(body) + if err != nil { + return + } + signer = key.ID().String() + err = json.Unmarshal(out, &req) + return +}