From 14963269dc63bf038163a851e521d6815ab5f514 Mon Sep 17 00:00:00 2001 From: Julio Capote Date: Sat, 7 Jan 2023 17:54:38 -0500 Subject: avatar support --- config/config.go | 12 +++++---- http/router.go | 12 +++++++++ models/avatar.go | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++ registry/registry.go | 57 ++++++++++++++++++++++++++++++++++++++++- sample-config.toml | 4 ++- urls/urls.go | 4 +++ views/actor.go | 21 ++++++++++++++-- 7 files changed, 172 insertions(+), 9 deletions(-) create mode 100644 models/avatar.go diff --git a/config/config.go b/config/config.go index 3c6cdde..e92b725 100644 --- a/config/config.go +++ b/config/config.go @@ -9,9 +9,11 @@ type Config struct { } type Handler struct { - Name string - Exec string - Rpc string - DedupWindow time.Duration - Interval time.Duration + Name string + AvatarUrl string + AvatarContentType string + Exec string + Rpc string + DedupWindow time.Duration + Interval time.Duration } diff --git a/http/router.go b/http/router.go index 4c251df..6a43499 100644 --- a/http/router.go +++ b/http/router.go @@ -2,6 +2,7 @@ package http import ( "bytes" + "fmt" "io" "net/http" @@ -57,6 +58,17 @@ func (s *Router) Start(zapWriter io.Writer) { render(c, resource, err) }) + // Actor avatar + router.GET("/actors/:actor/avatar", func(c *gin.Context) { + actorParam := c.Param("actor") + avatarBytes, mediaType, err := s.registry.ActorAvatar(actorParam) + if err != nil || avatarBytes == nil || mediaType == "" { + c.Data(404, "text/plain", []byte("404 page not found")) + } + c.Header("Content-Length", fmt.Sprintf("%d", len(avatarBytes))) + c.Data(200, mediaType, avatarBytes) + }) + // Actor Followers router.GET("/actors/:actor/followers", func(c *gin.Context) { actorParam := c.Param("actor") diff --git a/models/avatar.go b/models/avatar.go new file mode 100644 index 0000000..65c690f --- /dev/null +++ b/models/avatar.go @@ -0,0 +1,71 @@ +package models + +import ( + "bytes" + "encoding/gob" + "fmt" + + "git.capotej.com/capotej/communique/config" + "github.com/dgraph-io/badger/v3" +) + +type Avatar struct { + Handler config.Handler + ContentType string + Bytes []byte +} + +// used for lookup purposes (count, collect, find) +func NewAvatar(h config.Handler) *Avatar { + aso := &Avatar{Handler: h} + return aso +} + +func CreateAvatar(h config.Handler, contentType string, bytes []byte) (*Avatar, error) { + aso := &Avatar{ + Handler: h, + ContentType: contentType, + Bytes: bytes, + } + return aso, nil +} + +func (a *Avatar) Name() string { + return "Avatar" +} + +func (a *Avatar) Key() string { + keyBase := fmt.Sprintf("avatars:%s", a.Handler.Name) + return keyBase +} + +func (a *Avatar) DedupKey() string { + return a.Key() +} + +func (a *Avatar) Keybase() string { + return a.Key() +} + +func (a *Avatar) SaveDedup(txn *badger.Txn) error { + txn.Discard() // nothing to do here + return nil +} + +func (a *Avatar) Save(txn *badger.Txn) error { + if a.Bytes == nil { + return fmt.Errorf("bytes not set") + } + if a.ContentType == "" { + return fmt.Errorf("content type not set") + } + + var network bytes.Buffer + enc := gob.NewEncoder(&network) + err := enc.Encode(a) + if err != nil { + return fmt.Errorf("could not encode Avatar: %w", err) + } + e := badger.NewEntry([]byte(a.Key()), network.Bytes()) + return txn.SetEntry(e) +} diff --git a/registry/registry.go b/registry/registry.go index aa61d58..e50c6a5 100644 --- a/registry/registry.go +++ b/registry/registry.go @@ -9,6 +9,7 @@ import ( "encoding/pem" "fmt" "io" + "io/ioutil" "net/http" "net/url" "strings" @@ -47,6 +48,10 @@ func NewRegistry(cfg config.Config, persister *models.Persister, log *zap.Sugare if err != nil { return nil, err } + err = persistAvatarIfFound(v, persister) + if err != nil { + return nil, err + } } reg.delivery, err = delivery.NewSigned(persister) if err != nil { @@ -67,6 +72,36 @@ func generateKeypairIfNeeded(v config.Handler, p *models.Persister) error { return nil } +func persistAvatarIfFound(v config.Handler, p *models.Persister) error { + if v.AvatarUrl == "" || v.AvatarContentType == "" { + return nil + } + resp, err := http.Get(v.AvatarUrl) + if resp.StatusCode != 200 { + return fmt.Errorf("request to avatarUrl %s failed", v.AvatarUrl) + } + contentType := resp.Header.Get("content-type") + if contentType != v.AvatarContentType { + return fmt.Errorf("avatarUrl response content-type '%s' does match avatarContentType '%s'", contentType, v.AvatarContentType) + } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + if len(body) == 0 { + return fmt.Errorf("avatarUrl response was empty") + } + kp, err := models.CreateAvatar(v, v.AvatarContentType, body) + if err != nil { + return err + } + err = p.Store(kp) + if err != nil { + return err + } + return nil +} + func (r *Registry) Actor(name string) (map[string]interface{}, error) { handler := r.findByName(name) if handler == nil { @@ -91,7 +126,27 @@ func (r *Registry) Actor(name string) (map[string]interface{}, error) { Bytes: x509.MarshalPKCS1PublicKey(&privKey.PublicKey), }, ) - return views.RenderActor(handler.handlerCfg.Name, r.cfg.Domain, string(pemdata)) + return views.RenderActor(handler.handlerCfg.Name, r.cfg.Domain, string(pemdata), handler.handlerCfg.AvatarContentType) +} + +func (r *Registry) ActorAvatar(name string) ([]byte, string, error) { + handler := r.findByName(name) + if handler == nil { + return nil, "", nil + } + aso := models.NewAvatar(handler.handlerCfg) + result, err := r.persister.Find(aso) + if err != nil { + return nil, "", err + } + buf := bytes.NewBuffer(result) + dec := gob.NewDecoder(buf) + var avatar models.Avatar + err = dec.Decode(&avatar) + if err != nil { + return nil, "", err + } + return avatar.Bytes, handler.handlerCfg.AvatarContentType, nil } func (r *Registry) OutboxCollection(name string) (map[string]interface{}, error) { diff --git a/sample-config.toml b/sample-config.toml index 9f8a3fa..d724689 100644 --- a/sample-config.toml +++ b/sample-config.toml @@ -5,6 +5,8 @@ dbPath = "bub.db" name = "sample" rpc = "cgi" # rename to protocol? exec = "sample-cgi-handler.sh" +avatarUrl = "https://loremflickr.com/320/240/dog" +avatarContentType = "image/jpeg" interval = "1h" dedupWindow = "5h" @@ -19,4 +21,4 @@ dedupWindow = "5h" # name = "another" # rpc = "cgi" # exec = "another-cgi-handler.sh" -# intervalSeconds = "10" \ No newline at end of file +# intervalSeconds = "10" diff --git a/urls/urls.go b/urls/urls.go index 26370a4..69c3336 100644 --- a/urls/urls.go +++ b/urls/urls.go @@ -37,6 +37,10 @@ func UrlProfileKey(name, domain string) (*url.URL, error) { return linkTo("outbox", domain, "actors", name+"#key") } +func UrlActorAvatar(name, domain string) (*url.URL, error) { + return linkTo("outbox", domain, "actors", name, "avatar") +} + func UrlFollowers(name, domain string) (*url.URL, error) { return linkTo("outbox", domain, "actors", name, "followers") } diff --git a/views/actor.go b/views/actor.go index 48aaa0b..bc89f95 100644 --- a/views/actor.go +++ b/views/actor.go @@ -7,7 +7,7 @@ import ( "github.com/go-fed/activity/streams" ) -func RenderActor(name, domain, pem string) (map[string]interface{}, error) { +func RenderActor(name, domain, pem, mediaType string) (map[string]interface{}, error) { inbox, err := urls.UrlInbox(name, domain) if err != nil { return nil, err @@ -34,6 +34,11 @@ func RenderActor(name, domain, pem string) (map[string]interface{}, error) { return nil, err } + actorAvatarUrl, err := urls.UrlActorAvatar(name, domain) + if err != nil { + return nil, err + } + followingUrl, err := urls.UrlFollowing(name, domain) if err != nil { return nil, err @@ -51,6 +56,18 @@ func RenderActor(name, domain, pem string) (map[string]interface{}, error) { p.SetActivityStreamsInbox(inboxProp) p.SetActivityStreamsOutbox(outboxProp) + image := streams.NewActivityStreamsImage() + mediaTypeProp := streams.NewActivityStreamsMediaTypeProperty() + mediaTypeProp.Set(mediaType) + image.SetActivityStreamsMediaType(mediaTypeProp) + urlProp := streams.NewActivityStreamsUrlProperty() + urlProp.AppendIRI(actorAvatarUrl) + image.SetActivityStreamsUrl(urlProp) + + iconProp := streams.NewActivityStreamsIconProperty() + iconProp.AppendActivityStreamsImage(image) + p.SetActivityStreamsIcon(iconProp) + nameProp := streams.NewActivityStreamsNameProperty() nameProp.AppendXMLSchemaString(name) p.SetActivityStreamsName(nameProp) @@ -59,7 +76,7 @@ func RenderActor(name, domain, pem string) (map[string]interface{}, error) { usernameProp.SetXMLSchemaString(name) p.SetActivityStreamsPreferredUsername(usernameProp) - urlProp := streams.NewActivityStreamsUrlProperty() + urlProp = streams.NewActivityStreamsUrlProperty() urlProp.AppendIRI(actorUrl) p.SetActivityStreamsUrl(urlProp) -- cgit v1.2.3