diff options
-rw-r--r-- | controller/controller.go | 408 |
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 ®, 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 +} |