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:
parent
e2f2efda13
commit
3fccb3ae5f
|
@ -6,7 +6,6 @@ steps:
|
|||
- name: build-and-test
|
||||
image: r.mills.io/prologic/golang-alpine:latest
|
||||
commands:
|
||||
- make deps
|
||||
- make build
|
||||
- make test
|
||||
|
||||
|
|
|
@ -16,7 +16,9 @@
|
|||
/cmd/saltyd/saltyd
|
||||
/cmd/salty-chat/salty-chat
|
||||
|
||||
/data/*.db
|
||||
/data/*.key
|
||||
/data/avatars
|
||||
/data/.well-known
|
||||
|
||||
/echobot.sh
|
||||
|
|
21
Makefile
21
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 \
|
||||
|
|
28
client.go
28
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
|
||||
}
|
||||
|
|
|
@ -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{
|
||||
Use: "chat <user>",
|
||||
Short: "Creates a chat with a specific user",
|
||||
Use: "chat <user>",
|
||||
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.`,
|
||||
|
|
|
@ -18,8 +18,9 @@ const (
|
|||
)
|
||||
|
||||
var sendCmd = &cobra.Command{
|
||||
Use: "send <user> [<message>]",
|
||||
Short: "Send a message to a user",
|
||||
Use: "send <user> [<message>]",
|
||||
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.
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
5
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
|
||||
|
|
11
go.sum
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.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=
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:36a4b38a1e641b50be9dcc9094dec0dbf623dbb6cb6d74597328921c4ab8261d
|
||||
size 28479714
|
||||
oid sha256:715314606d8f94c1c3d1ea181008298cb970fec7a273eedaa11865dc28ca096e
|
||||
size 28479770
|
||||
|
|
23
types.go
23
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
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue