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

View File

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

2
.gitignore vendored
View File

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

5
Corefile Normal file
View File

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

View File

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

View File

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

67
cmd/salty-chat/avatar.go Normal file
View 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! 🥳")
}

View File

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

View File

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

View File

@ -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
data/avatars/.gitkeep Normal file
View File

14
db.home.arpa Normal file
View 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
View File

@ -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
View File

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

View File

@ -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)
}
}

View File

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

View File

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

View File

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

View File

@ -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()

View File

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

View File

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

View File

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

View File

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