6
1
mirror of https://git.mills.io/saltyim/saltyim.git synced 2024-06-16 03:48:24 +00:00

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 <prologic@shortcircuit.net.au>
Reviewed-on: https://git.mills.io/saltyim/saltyim/pulls/116
This commit is contained in:
James Mills 2022-04-02 02:59:39 +00:00
parent e2f2efda13
commit 3fccb3ae5f
22 changed files with 440 additions and 67 deletions

@ -6,7 +6,6 @@ steps:
- name: build-and-test - name: build-and-test
image: r.mills.io/prologic/golang-alpine:latest image: r.mills.io/prologic/golang-alpine:latest
commands: commands:
- make deps
- make build - make build
- make test - make test

2
.gitignore vendored

@ -16,7 +16,9 @@
/cmd/saltyd/saltyd /cmd/saltyd/saltyd
/cmd/salty-chat/salty-chat /cmd/salty-chat/salty-chat
/data/*.db
/data/*.key /data/*.key
/data/avatars
/data/.well-known /data/.well-known
/echobot.sh /echobot.sh

5
Corefile Normal file

@ -0,0 +1,5 @@
home.arpa:5300 {
log
errors
file db.home.arpa
}

@ -31,23 +31,18 @@ preflight: ## Run preflight checks to ensure you have the right build tools
deps: ## Install any dependencies required deps: ## Install any dependencies required
@$(GOCMD) install github.com/jsha/minica@latest @$(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 certs: certs/minica-key.pem certs/minica.pem certs/_.home.arpa/key.pem certs/_.home.arpa/cert.pem
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
@/bin/sh -c 'cd certs && minica --domains salty.home.arpa' @/bin/sh -c 'cd certs && minica --domains salty.home.arpa'
pwa-dev : DEBUG=1 dev : DEBUG=1
pwa-dev : build certs ## Build debug version of saltyd and PWA 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/... @CGO_ENABLED=1 $(GOCMD) build -tags "embed" ./cmd/saltyd/...
@./saltyd -D -b :https -u https://salty.home.arpa \ @./saltyd -D -b :https --tls \
--tls --tls-key ./certs/salty.home.arpa/key.pem \ --tls-key ./certs/_.home.arpa/key.pem \
--tls-cert ./certs/salty.home.arpa/cert.pem \ --tls-cert ./certs/_.home.arpa/cert.pem
--svc-user salty@salty.home.arpa
cli: ## Build the salty-chat command-line client and tui cli: ## Build the salty-chat command-line client and tui
@$(GOCMD) build -tags "netgo static_build" -installsuffix netgo \ @$(GOCMD) build -tags "netgo static_build" -installsuffix netgo \

@ -4,8 +4,10 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"net/url" "net/url"
"os" "os"
"path" "path"
@ -393,3 +395,29 @@ func (cli *Client) Register() error {
return nil 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
}

67
cmd/salty-chat/avatar.go Normal file

@ -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 <file>",
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! 🥳")
}

@ -13,8 +13,9 @@ import (
) )
var chatCmd = &cobra.Command{ var chatCmd = &cobra.Command{
Use: "chat <user>", Use: "chat <user>",
Short: "Creates a chat with a specific user", Aliases: []string{"talk"},
Short: "Creates a chat with a specific user",
Long: `This command creates a chat with the specified user by discovering Long: `This command creates a chat with the specified user by discovering
and subscribing to your endpoint and prompts for input and sends encrypted and subscribing to your endpoint and prompts for input and sends encrypted
messages to the user via their discovered endpoint.`, messages to the user via their discovered endpoint.`,

@ -18,8 +18,9 @@ const (
) )
var sendCmd = &cobra.Command{ var sendCmd = &cobra.Command{
Use: "send <user> [<message>]", Use: "send <user> [<message>]",
Short: "Send a message to a user", Aliases: []string{"write", "post"},
Short: "Send a message to a user",
Long: `This command attempts to lookup the user's Salty Config by using 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 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. After it will attempt to send the message via a HTTP POST to their Endpoint.

@ -29,14 +29,14 @@ var (
tlsCert string tlsCert string
// Basic options // Basic options
data string data string
store string store string
baseURL string baseURL string
svcUser string primaryDomain string
// Oeprator // Oeprator
adminUser string adminUser string
adminEmail string supportEmail string
) )
const ( const (
@ -68,11 +68,11 @@ func init() {
flag.StringVarP(&data, "data", "d", internal.DefaultData, "data directory") flag.StringVarP(&data, "data", "d", internal.DefaultData, "data directory")
flag.StringVarP(&store, "store", "s", internal.DefaultStore, "store to use") flag.StringVarP(&store, "store", "s", internal.DefaultStore, "store to use")
flag.StringVarP(&baseURL, "base-url", "u", internal.DefaultBaseURL, "base url 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 // Oeprator
flag.StringVarP(&adminUser, "admin-user", "A", internal.DefaultAdminUser, "default admin user to use") flag.StringVarP(&adminUser, "admin-user", "A", internal.DefaultAdminUser, "public key of admin user")
flag.StringVarP(&adminEmail, "admin-email", "E", internal.DefaultAdminEmail, "default admin user email") flag.StringVarP(&supportEmail, "support-email", "E", internal.DefaultSupportEmail, "support email")
} }
func flagNameFromEnvironmentName(s string) string { func flagNameFromEnvironmentName(s string) string {
@ -127,11 +127,11 @@ func main() {
internal.WithData(data), internal.WithData(data),
internal.WithStore(store), internal.WithStore(store),
internal.WithBaseURL(baseURL), internal.WithBaseURL(baseURL),
internal.WithSvcUser(svcUser), internal.WithPrimaryDomain(primaryDomain),
// Oeprator // Oeprator
internal.WithAdminUser(adminUser), internal.WithAdminUser(adminUser),
internal.WithAdminEmail(adminEmail), internal.WithSupportEmail(supportEmail),
) )
if err != nil { if err != nil {
log.WithError(err).Fatal("error creating server") log.WithError(err).Fatal("error creating server")

0
data/avatars/.gitkeep Normal file

14
db.home.arpa Normal file

@ -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.

5
go.mod

@ -7,10 +7,15 @@ go 1.18
require ( require (
github.com/andybalholm/brotli v1.0.4 github.com/andybalholm/brotli v1.0.4
github.com/avast/retry-go v2.7.0+incompatible 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/likexian/doh-go v0.6.4
github.com/mattn/go-isatty v0.0.14 github.com/mattn/go-isatty v0.0.14
github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-homedir v1.1.0
github.com/mlctrez/goapp-mdc v0.2.6 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/oklog/ulid/v2 v2.0.2
github.com/sasha-s/go-deadlock v0.3.1 github.com/sasha-s/go-deadlock v0.3.1
github.com/sirupsen/logrus v1.8.1 github.com/sirupsen/logrus v1.8.1

11
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.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 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/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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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 h1:65LapgsVGYE/ET3yLMmnprQlZjEjCLt68V+Paz+dq+g=
github.com/dim13/crc24 v0.0.0-20190831075008-15d874f06514/go.mod h1:TejqRSRwQ36N6MSUaGN8WipY4g3bgf5HeEi9fApCxG0= 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.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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 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/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.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= 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/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/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= 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/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-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/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 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 h1:r4fFzBm+bv0wNKNh5eXTwU7i85y5x+uwkxCUTNVQqLc=
github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68= github.com/oklog/ulid/v2 v2.0.2/go.mod h1:mtBL0Qe/0HAx6/a4Z30qxVIAL1eQDweXq5lxOEiwQ68=

@ -32,8 +32,13 @@ func (a *API) initRoutes() {
router := a.router.Group("/api/v1") router := a.router.Group("/api/v1")
router.GET("/ping", a.PingEndpoint()) router.GET("/ping", a.PingEndpoint())
// Lookup and Send support for Web / PWA clients
router.GET("/lookup/:addr", a.LookupEndpoint()) router.GET("/lookup/:addr", a.LookupEndpoint())
router.POST("/send", a.SendEndpoint()) router.POST("/send", a.SendEndpoint())
// Avatar Service
router.POST("/avatar", a.AvatarEndpoint())
} }
// PingEndpoint ... // PingEndpoint ...
@ -78,3 +83,25 @@ func (a *API) SendEndpoint() httprouter.Handle {
http.Error(w, "Message Accepted", http.StatusAccepted) 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)
}
}

@ -14,13 +14,14 @@ type Config struct {
TLSKey string `json:"-"` TLSKey string `json:"-"`
TLSCert string `json:"-"` TLSCert string `json:"-"`
Data string `json:"-"` Data string `json:"-"`
Store string `json:"-"` Store string `json:"-"`
BaseURL string
SvcUser string
AdminUser string `json:"-"` BaseURL string
AdminEmail string `json:"-"` PrimaryDomain string
AdminUser string `json:"-"`
SupportEmail string `json:"-"`
baseURL *url.URL baseURL *url.URL
} }

@ -1,11 +1,18 @@
package internal package internal
import ( import (
"fmt"
"image/png"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strings"
"time"
"git.mills.io/prologic/useragent" "git.mills.io/prologic/useragent"
securejoin "github.com/cyphar/filepath-securejoin"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
log "github.com/sirupsen/logrus"
) )
func (s *Server) NotFoundHandler(w http.ResponseWriter, r *http.Request) { 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) 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
}
}
}

@ -7,6 +7,8 @@ import (
) )
const ( const (
serviceUser = "salty"
// InvalidConfigValue is the constant value for invalid config values // InvalidConfigValue is the constant value for invalid config values
// which must be changed for production configurations before successful // which must be changed for production configurations before successful
// startup // startup
@ -28,28 +30,31 @@ const (
DefaultData = "./data" DefaultData = "./data"
// DefaultStore is the default data store used for accounts, sessions, etc // 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 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 // DefaultPrimaryDomain is the default primary domain delegated to this broker
DefaultSvcUser = "salty@localhost" DefaultPrimaryDomain = "home.arpa"
// DefaultAdminUser is the default publickye to grant admin privileges to // DefaultAdminUser is the default publickye to grant admin privileges to
DefaultAdminUser = "" DefaultAdminUser = ""
// DefaultAdminEmail is the default email of the admin user used in support requests // DefaultSupportEmail is the default email of the admin user used in support requests
DefaultAdminEmail = "support@salty.im" DefaultSupportEmail = "support@" + DefaultPrimaryDomain
) )
func NewConfig() *Config { func NewConfig() *Config {
return &Config{ return &Config{
Debug: DefaultDebug, Debug: DefaultDebug,
Store: DefaultStore, Store: DefaultStore,
BaseURL: DefaultBaseURL,
AdminUser: DefaultAdminUser, BaseURL: DefaultBaseURL,
AdminEmail: DefaultAdminEmail, 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 // WithAdminUser sets the Admin user used for granting special features to
func WithAdminUser(adminUser string) Option { func WithAdminUser(adminUser string) Option {
return func(cfg *Config) error { 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 // WithSupportEmail sets the Support email used to contact the operator of this broker
func WithAdminEmail(adminEmail string) Option { func WithSupportEmail(supportEmail string) Option {
return func(cfg *Config) error { return func(cfg *Config) error {
cfg.AdminEmail = adminEmail cfg.SupportEmail = supportEmail
return nil
}
}
// WithSvcUser sets the internal service user address
func WithSvcUser(user string) Option {
return func(c *Config) error {
c.SvcUser = user
return nil return nil
} }
} }

@ -149,7 +149,7 @@ func (s *Server) ListenAndServe() error {
m := &autocert.Manager{ m := &autocert.Manager{
Cache: autocert.DirCache(filepath.Join(s.config.Data, acmeDir)), Cache: autocert.DirCache(filepath.Join(s.config.Data, acmeDir)),
Prompt: autocert.AcceptTOS, Prompt: autocert.AcceptTOS,
Email: s.config.AdminEmail, Email: s.config.SupportEmail,
HostPolicy: autocert.HostWhitelist(s.config.baseURL.Hostname()), HostPolicy: autocert.HostWhitelist(s.config.baseURL.Hostname()),
} }
s.server.TLSConfig = m.TLSConfig() s.server.TLSConfig = m.TLSConfig()
@ -228,10 +228,12 @@ func (s *Server) setupCronJobs() error {
} }
func (s *Server) setupServiceUser() 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 // create our addr
me, err := saltyim.ParseAddr(s.config.SvcUser) me, err := saltyim.ParseAddr(svcUser)
if err != nil { if err != nil {
return err return err
} }
@ -288,6 +290,10 @@ func (s *Server) runStartupJobs() {
} }
func (s *Server) initRoutes() { func (s *Server) initRoutes() {
//
// PWA
//
app := &app.Handler{ app := &app.Handler{
Name: "Salty Chat", Name: "Salty Chat",
ShortName: "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, "/app-worker.js", app)
s.router.Handler(http.MethodGet, "/manifest.webmanifest", app) s.router.Handler(http.MethodGet, "/manifest.webmanifest", app)
// Discovery
s.router.GET("/.well-known/salty/:config", s.ConfigHandler()) s.router.GET("/.well-known/salty/:config", s.ConfigHandler())
// Inbox
s.router.GET("/inbox/:inbox", s.InboxHandler()) s.router.GET("/inbox/:inbox", s.InboxHandler())
s.router.POST("/inbox/:inbox", s.InboxHandler()) s.router.POST("/inbox/:inbox", s.InboxHandler())
// Avatar Service
s.router.GET("/avatar/:hash", s.AvatarHandler())
} }
// NewServer ... // NewServer ...
@ -429,14 +440,14 @@ func NewServer(bind string, options ...Option) (*Server, error) {
log.Info("succeessfully setup service user") log.Info("succeessfully setup service user")
server.setupMetrics() 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 interesting configuration options
log.Infof("Debug: %t", server.config.Debug) log.Infof("Debug: %t", server.config.Debug)
log.Infof("Base URL: %s", server.config.BaseURL) 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 User: %s", server.config.AdminUser)
log.Infof("Admin Email: %s", server.config.AdminEmail) log.Infof("Support Email: %s", server.config.SupportEmail)
api.initRoutes() api.initRoutes()
server.initRoutes() server.initRoutes()

@ -1,6 +1,7 @@
package internal package internal
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@ -10,7 +11,9 @@ import (
) )
const ( const (
wellknownPath = ".well-known/salty" wellknownPath = ".well-known/salty"
avatarsPath = "avatars"
avatarResolution = 80 // 80x80 px
) )
func CreateConfig(conf *Config, hash string, key string) error { func CreateConfig(conf *Config, hash string, key string) error {
@ -50,3 +53,20 @@ func CreateConfig(conf *Config, hash string, key string) error {
return nil 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
}

@ -1,6 +1,22 @@
package internal 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 // FileExists returns true if the given file exists
func FileExists(name string) bool { func FileExists(name string) bool {
@ -11,3 +27,87 @@ func FileExists(name string) bool {
} }
return true 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
}

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:36a4b38a1e641b50be9dcc9094dec0dbf623dbb6cb6d74597328921c4ab8261d oid sha256:715314606d8f94c1c3d1ea181008298cb970fec7a273eedaa11865dc28ca096e
size 28479714 size 28479770

@ -54,3 +54,26 @@ func NewSendRequest(r io.Reader) (req SendRequest, signer string, err error) {
err = json.Unmarshal(out, &req) err = json.Unmarshal(out, &req)
return 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
}