aboutsummaryrefslogtreecommitdiff
path: root/controller
diff options
context:
space:
mode:
Diffstat (limited to 'controller')
-rw-r--r--controller/controller.go408
1 files changed, 408 insertions, 0 deletions
diff --git a/controller/controller.go b/controller/controller.go
new file mode 100644
index 0000000..18efb0c
--- /dev/null
+++ b/controller/controller.go
@@ -0,0 +1,408 @@
+package controller
+
+import (
+ "bytes"
+ "context"
+ "crypto/x509"
+ "encoding/gob"
+ "encoding/json"
+ "encoding/pem"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "git.capotej.com/capotej/communique/config"
+ "git.capotej.com/capotej/communique/delivery"
+ "git.capotej.com/capotej/communique/models"
+ "git.capotej.com/capotej/communique/tools"
+ "git.capotej.com/capotej/communique/urls"
+ "git.capotej.com/capotej/communique/views"
+ "github.com/go-fed/activity/streams"
+ "github.com/go-fed/activity/streams/vocab"
+ "go.uber.org/zap"
+)
+
+type Handler struct {
+ handlerCfg config.Handler
+}
+
+// TODO rename to controller and controller.go
+type Controller struct {
+ cfg config.Config
+ persister *models.Persister
+ handlerMap map[string]Handler
+ log *zap.SugaredLogger
+ delivery *delivery.Signed
+}
+
+func NewController(cfg config.Config, persister *models.Persister, log *zap.SugaredLogger) (*Controller, error) {
+ reg := Controller{cfg: cfg, persister: persister, log: log}
+ reg.handlerMap = make(map[string]Handler)
+ var err error
+ for _, v := range cfg.Handlers {
+ reg.handlerMap[v.Name] = Handler{handlerCfg: v}
+ err = generateKeypairIfNeeded(v, persister)
+ 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 {
+ return nil, err
+ }
+ return &reg, nil
+}
+
+func generateKeypairIfNeeded(v config.Handler, p *models.Persister) error {
+ kp, err := models.CreateKeypair(v)
+ if err != nil {
+ return err
+ }
+ err = p.Store(kp)
+ if err != nil {
+ return err
+ }
+ 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 *Controller) Actor(name string) (map[string]interface{}, error) {
+ handler := r.findByName(name)
+ if handler == nil {
+ return nil, nil
+ }
+ aso := models.NewKeypair(handler.handlerCfg)
+ result, err := r.persister.Find(aso)
+ if err != nil {
+ return nil, err
+ }
+ buf := bytes.NewBuffer(result)
+ dec := gob.NewDecoder(buf)
+ var keypair models.Keypair
+ err = dec.Decode(&keypair)
+ if err != nil {
+ return nil, err
+ }
+ privKey := &keypair.PrivateKey
+ pemdata := pem.EncodeToMemory(
+ &pem.Block{
+ Type: "RSA PUBLIC KEY",
+ Bytes: x509.MarshalPKCS1PublicKey(&privKey.PublicKey),
+ },
+ )
+ return views.RenderActor(handler.handlerCfg.Name, r.cfg.Domain, string(pemdata), handler.handlerCfg.AvatarContentType, handler.handlerCfg.Summary)
+}
+
+func (r *Controller) 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 *Controller) OutboxCollection(name string) (map[string]interface{}, error) {
+ handler := r.findByName(name)
+ if handler == nil {
+ return nil, nil
+ }
+ aso := models.NewOutboxItem(handler.handlerCfg)
+ page, err := r.persister.Collect(aso)
+ if err != nil {
+ return nil, err
+ }
+ var outboxItems []models.OutboxItem
+ for _, v := range page { //TODO pagination
+ buf := bytes.NewBuffer(v)
+ dec := gob.NewDecoder(buf)
+ var outboxItem models.OutboxItem
+ err = dec.Decode(&outboxItem)
+ if err != nil {
+ return nil, err
+ }
+ outboxItems = append(outboxItems, outboxItem)
+ }
+ return views.RenderOutboxCollection(handler.handlerCfg.Name, r.cfg.Domain, outboxItems)
+}
+
+func (r *Controller) Following(name string) (map[string]interface{}, error) {
+ handler := r.findByName(name)
+ if handler == nil {
+ return nil, nil
+ }
+ profile, err := urls.UrlProfile(name, r.cfg.Domain)
+ if err != nil {
+ return nil, err
+ }
+ following, err := urls.UrlFollowing(name, r.cfg.Domain)
+ if err != nil {
+ return nil, err
+ }
+ result := make(map[string]interface{})
+ result["@context"] = "https://www.w3.org/ns/activitystreams"
+ result["attributedTo"] = profile.String()
+ result["id"] = following.String()
+ result["totalItems"] = 0
+ result["orderedItems"] = []bool{}
+ result["type"] = "OrderedCollection"
+ return result, nil
+}
+
+func (r *Controller) Followers(name string) (map[string]interface{}, error) {
+ handler := r.findByName(name)
+ if handler == nil {
+ return nil, nil
+ }
+ profile, err := urls.UrlProfile(name, r.cfg.Domain)
+ if err != nil {
+ return nil, err
+ }
+ followers, err := urls.UrlFollowers(name, r.cfg.Domain)
+ if err != nil {
+ return nil, err
+ }
+ result := make(map[string]interface{})
+ result["@context"] = "https://www.w3.org/ns/activitystreams"
+ result["attributedTo"] = profile.String()
+ result["id"] = followers.String()
+ result["totalItems"] = 0
+ result["orderedItems"] = []bool{}
+ result["type"] = "OrderedCollection"
+ return result, nil
+}
+
+func (r *Controller) Inbox(name string, req *http.Request, payload []byte) error {
+ handler := r.findByName(name)
+ if handler == nil {
+ return nil
+ }
+
+ domainUrl, err := url.Parse(r.cfg.Domain)
+ if err != nil {
+ return err
+ }
+
+ logger := r.log.With("type", "inbox")
+
+ if req.Host != domainUrl.Host {
+ logger.Warnf("%s != %s, configured domain should match incoming Host: header otherwise things may not work right. You'll need to enable 'ProxyPreserveHost' (for apache2) or proxy_set_header Host $host; (for nginx)", req.Host, r.cfg.Domain)
+ }
+
+ ctx := context.Background()
+
+ person, verifyError := tools.VerifyRequest(ctx, req, r.log)
+ if verifyError != nil {
+ return verifyError
+ }
+
+ var followData map[string]interface{}
+ err = json.Unmarshal(payload, &followData)
+ if err != nil {
+ return err
+ }
+
+ actorUrl, err := urls.UrlProfile(handler.handlerCfg.Name, r.cfg.Domain)
+ if err != nil {
+ return err
+ }
+
+ resolver, err := streams.NewJSONResolver(func(c context.Context, follow vocab.ActivityStreamsFollow) error {
+ // Follow
+ idProp := person.GetJSONLDId()
+ idPropUrl := idProp.Get()
+ inboxProp := person.GetActivityStreamsInbox()
+ inboxUrl := inboxProp.GetIRI()
+ logger.With("actor", idPropUrl).With("inbox", inboxUrl).Debugf("follow")
+
+ actorKeyUrl, err := urls.UrlProfileKey(handler.handlerCfg.Name, r.cfg.Domain)
+ if err != nil {
+ return err
+ }
+ err = r.deliverAcceptToInbox(inboxUrl, actorUrl, actorKeyUrl, follow, handler.handlerCfg)
+ if err != nil {
+ return err
+ }
+ return r.subscribeActorToHandler(handler.handlerCfg, inboxUrl.String())
+ }, func(c context.Context, note vocab.ActivityStreamsUndo) error {
+ // Unfollow
+ idProp := person.GetJSONLDId()
+ idPropUrl := idProp.Get()
+ inboxProp := person.GetActivityStreamsInbox()
+ inboxUrl := inboxProp.GetIRI()
+ logger.With("actor", idPropUrl).With("inbox", inboxUrl).Debugf("unfollow/undo")
+ return r.unsubscribeActorToHandler(handler.handlerCfg, inboxUrl.String())
+ })
+ err = resolver.Resolve(ctx, followData)
+
+ return err
+}
+func (r *Controller) subscribeActorToHandler(handler config.Handler, inboxUrl string) error {
+ aso, err := models.CreateSubscription(handler, inboxUrl)
+ r.persister.Store(aso)
+ return err
+}
+
+func (r *Controller) unsubscribeActorToHandler(handler config.Handler, inboxUrl string) error {
+ aso, err := models.CreateSubscription(handler, inboxUrl)
+ r.persister.Delete(aso)
+ return err
+}
+
+// TODO should probably be in its own delivery package
+func (r *Controller) deliverAcceptToInbox(url, actorUrl, actorKeyUrl *url.URL, follow vocab.ActivityStreamsFollow, handler config.Handler) error {
+ accept := streams.NewActivityStreamsAccept()
+ actorProp := streams.NewActivityStreamsActorProperty()
+ actorProp.AppendIRI(actorUrl)
+ accept.SetActivityStreamsActor(actorProp)
+ objProp := streams.NewActivityStreamsObjectProperty()
+ objProp.AppendActivityStreamsFollow(follow)
+ accept.SetActivityStreamsObject(objProp)
+ payload, err := streams.Serialize(accept)
+ if err != nil {
+ return err
+ }
+ jsonData, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+
+ request, err := r.delivery.SignedRequest(handler, jsonData, url, actorKeyUrl)
+ if err != nil {
+ return err
+ }
+
+ r.log.With(
+ "type",
+ "delivery",
+ ).With(
+ "inbox",
+ url.String(),
+ ).With(
+ "digest",
+ request.Header.Get("digest"),
+ ).With(
+ "signature",
+ request.Header.Get("signature"),
+ ).With(
+ "actor",
+ actorUrl.String(),
+ ).Debug("sending signed accept request")
+
+ client := &http.Client{}
+ response, err := client.Do(request)
+ if err != nil {
+ return fmt.Errorf("could not send accept request: %w", err)
+ }
+ responseBody, err := io.ReadAll(response.Body)
+ defer response.Body.Close()
+ r.log.With("type", "delivery").With("response", responseBody).With("status", response.Status).Debugf("remote inbox response received")
+ return err
+}
+
+func (r *Controller) ActivityOrNote(activityOrNote, name, id string) (map[string]interface{}, error) {
+ handler := r.findByName(name)
+ if handler == nil {
+ return nil, nil
+ }
+ lookup := models.NewOutboxItem(handler.handlerCfg)
+ lookup.Id = []byte(id)
+ result, err := r.persister.Find(lookup)
+ if err != nil {
+ return nil, err
+ }
+ if result == nil {
+ return nil, nil
+ }
+ buf := bytes.NewBuffer(result)
+ dec := gob.NewDecoder(buf)
+ var outboxItem models.OutboxItem
+ err = dec.Decode(&outboxItem)
+ if err != nil {
+ return nil, err
+ }
+ if activityOrNote == "activity" {
+ return views.RenderActivity(handler.handlerCfg.Name, r.cfg.Domain, outboxItem)
+ }
+ return views.RenderNote(handler.handlerCfg.Name, r.cfg.Domain, outboxItem)
+}
+
+// This has to handle various lookup formats:
+// ?resource=acct:actor@domain
+// ?resource=actor@domain
+// ?resource=actor
+// ?resource=acct:actor
+func (r *Controller) Webfinger(fqn string) (*views.WebfingerResource, error) {
+ // Strip away acct: prefix, if found
+ fqn = strings.TrimPrefix(fqn, "acct:")
+
+ // Strip away @domain suffix, if found
+ domainUrl, err := url.Parse(r.cfg.Domain)
+ if err != nil {
+ return nil, err
+ }
+ hostname := "@" + domainUrl.Hostname()
+ fqn = strings.TrimSuffix(fqn, hostname)
+
+ // We should just have $actor left
+ handler := r.findByName(fqn)
+ if handler == nil {
+ return nil, nil
+ }
+ return views.RenderWebfinger(handler.handlerCfg.Name, r.cfg.Domain, hostname)
+}
+
+func (r *Controller) findByName(name string) *Handler {
+ handler, ok := r.handlerMap[name]
+ if !ok {
+ return nil
+ }
+ return &handler
+}