From 40add686913b7eb6edd5a780e37e7513b43a337f Mon Sep 17 00:00:00 2001 From: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com> Date: Thu, 27 May 2021 16:06:24 +0200 Subject: [PATCH] Notifications (#34) Notifications working for: * Mentions * Faves * New follow requests * New followers --- .../api/client/notification/notification.go | 66 ++ .../client/notification/notificationsget.go | 72 ++ internal/api/model/notification.go | 2 +- internal/api/model/status.go | 2 +- internal/db/db.go | 2 + internal/db/pg/pg.go | 29 + internal/federation/federating_db.go | 1052 ----------------- internal/federation/federatingdb/accept.go | 94 ++ internal/federation/federatingdb/create.go | 161 +++ internal/federation/federatingdb/db.go | 59 + internal/federation/federatingdb/delete.go | 86 ++ internal/federation/federatingdb/exists.go | 24 + internal/federation/federatingdb/followers.go | 55 + internal/federation/federatingdb/following.go | 55 + internal/federation/federatingdb/get.go | 83 ++ internal/federation/federatingdb/inbox.go | 73 ++ internal/federation/federatingdb/liked.go | 26 + internal/federation/federatingdb/lock.go | 70 ++ internal/federation/federatingdb/outbox.go | 71 ++ internal/federation/federatingdb/owns.go | 123 ++ internal/federation/federatingdb/undo.go | 89 ++ internal/federation/federatingdb/update.go | 142 +++ internal/federation/federatingdb/util.go | 187 +++ internal/federation/federatingprotocol.go | 28 +- internal/federation/federator.go | 9 +- internal/federation/util.go | 20 +- internal/gotosocial/actions.go | 7 +- internal/gtsmodel/notification.go | 70 ++ internal/gtsmodel/status.go | 2 +- internal/message/fromclientapiprocess.go | 25 +- internal/message/fromcommonprocess.go | 135 ++- internal/message/fromfederatorprocess.go | 38 +- internal/message/notificationsprocess.go | 24 + internal/message/processor.go | 3 + internal/message/statusprocess.go | 52 +- internal/typeutils/asinterfaces.go | 9 + internal/typeutils/astointernal.go | 44 +- internal/typeutils/converter.go | 31 +- internal/typeutils/internal.go | 76 ++ internal/typeutils/internaltoas.go | 12 +- internal/typeutils/internaltofrontend.go | 76 +- testrig/federatingdb.go | 6 +- 42 files changed, 2096 insertions(+), 1194 deletions(-) create mode 100644 internal/api/client/notification/notification.go create mode 100644 internal/api/client/notification/notificationsget.go delete mode 100644 internal/federation/federating_db.go create mode 100644 internal/federation/federatingdb/accept.go create mode 100644 internal/federation/federatingdb/create.go create mode 100644 internal/federation/federatingdb/db.go create mode 100644 internal/federation/federatingdb/delete.go create mode 100644 internal/federation/federatingdb/exists.go create mode 100644 internal/federation/federatingdb/followers.go create mode 100644 internal/federation/federatingdb/following.go create mode 100644 internal/federation/federatingdb/get.go create mode 100644 internal/federation/federatingdb/inbox.go create mode 100644 internal/federation/federatingdb/liked.go create mode 100644 internal/federation/federatingdb/lock.go create mode 100644 internal/federation/federatingdb/outbox.go create mode 100644 internal/federation/federatingdb/owns.go create mode 100644 internal/federation/federatingdb/undo.go create mode 100644 internal/federation/federatingdb/update.go create mode 100644 internal/federation/federatingdb/util.go create mode 100644 internal/gtsmodel/notification.go create mode 100644 internal/message/notificationsprocess.go create mode 100644 internal/typeutils/internal.go diff --git a/internal/api/client/notification/notification.go b/internal/api/client/notification/notification.go new file mode 100644 index 000000000..bc06b31e4 --- /dev/null +++ b/internal/api/client/notification/notification.go @@ -0,0 +1,66 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package notification + +import ( + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + // IDKey is for notification UUIDs + IDKey = "id" + // BasePath is the base path for serving the notification API + BasePath = "/api/v1/notifications" + // BasePathWithID is just the base path with the ID key in it. + // Use this anywhere you need to know the ID of the notification being queried. + BasePathWithID = BasePath + "/:" + IDKey + + // MaxIDKey is the url query for setting a max notification ID to return + MaxIDKey = "max_id" + // Limit key is for specifying maximum number of notifications to return. + LimitKey = "limit" +) + +// Module implements the ClientAPIModule interface for every related to posting/deleting/interacting with notifications +type Module struct { + config *config.Config + processor message.Processor + log *logrus.Logger +} + +// New returns a new notification module +func New(config *config.Config, processor message.Processor, log *logrus.Logger) api.ClientModule { + return &Module{ + config: config, + processor: processor, + log: log, + } +} + +// Route attaches all routes from this module to the given router +func (m *Module) Route(r router.Router) error { + r.AttachHandler(http.MethodGet, BasePath, m.NotificationsGETHandler) + return nil +} diff --git a/internal/api/client/notification/notificationsget.go b/internal/api/client/notification/notificationsget.go new file mode 100644 index 000000000..3e4970800 --- /dev/null +++ b/internal/api/client/notification/notificationsget.go @@ -0,0 +1,72 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package notification + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (m *Module) NotificationsGETHandler(c *gin.Context) { + l := m.log.WithFields(logrus.Fields{ + "func": "NotificationsGETHandler", + "request_uri": c.Request.RequestURI, + "user_agent": c.Request.UserAgent(), + "origin_ip": c.ClientIP(), + }) + l.Debugf("entering function") + + authed, err := oauth.Authed(c, true, true, true, true) // we don't really need an app here but we want everything else + if err != nil { + l.Errorf("error authing status faved by request: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "not authed"}) + return + } + + limit := 20 + limitString := c.Query(LimitKey) + if limitString != "" { + i, err := strconv.ParseInt(limitString, 10, 64) + if err != nil { + l.Debugf("error parsing limit string: %s", err) + c.JSON(http.StatusBadRequest, gin.H{"error": "couldn't parse limit query param"}) + return + } + limit = int(i) + } + + maxID := "" + maxIDString := c.Query(MaxIDKey) + if maxIDString != "" { + maxID = maxIDString + } + + notifs, errWithCode := m.processor.NotificationsGet(authed, limit, maxID) + if errWithCode != nil { + l.Debugf("error processing notifications get: %s", errWithCode.Error()) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + c.JSON(http.StatusOK, notifs) +} diff --git a/internal/api/model/notification.go b/internal/api/model/notification.go index c8d080e2a..2163251b4 100644 --- a/internal/api/model/notification.go +++ b/internal/api/model/notification.go @@ -41,5 +41,5 @@ type Notification struct { // OPTIONAL // Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls. - Status *Status `json:"status"` + Status *Status `json:"status,omitempty"` } diff --git a/internal/api/model/status.go b/internal/api/model/status.go index 2456d1a8f..963ef4f86 100644 --- a/internal/api/model/status.go +++ b/internal/api/model/status.go @@ -31,7 +31,7 @@ type Status struct { // Is this status marked as sensitive content? Sensitive bool `json:"sensitive"` // Subject or summary line, below which status content is collapsed until expanded. - SpoilerText string `json:"spoiler_text,omitempty"` + SpoilerText string `json:"spoiler_text"` // Visibility of this status. Visibility Visibility `json:"visibility"` // Primary language of this status. (ISO 639 Part 1 two-letter language code) diff --git a/internal/db/db.go b/internal/db/db.go index 9ad811580..e71484a6d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -284,6 +284,8 @@ type DB interface { // It will use the given filters and try to return as many statuses up to the limit as possible. GetHomeTimelineForAccount(accountID string, maxID string, sinceID string, minID string, limit int, local bool) ([]*gtsmodel.Status, error) + GetNotificationsForAccount(accountID string, limit int, maxID string) ([]*gtsmodel.Notification, error) + /* USEFUL CONVERSION FUNCTIONS */ diff --git a/internal/db/pg/pg.go b/internal/db/pg/pg.go index 7f65055d6..9b6c7a114 100644 --- a/internal/db/pg/pg.go +++ b/internal/db/pg/pg.go @@ -1138,6 +1138,35 @@ func (ps *postgresService) GetHomeTimelineForAccount(accountID string, maxID str return statuses, nil } +func (ps *postgresService) GetNotificationsForAccount(accountID string, limit int, maxID string) ([]*gtsmodel.Notification, error) { + notifications := []*gtsmodel.Notification{} + + q := ps.conn.Model(¬ifications).Where("target_account_id = ?", accountID) + + + if maxID != "" { + n := >smodel.Notification{} + if err := ps.conn.Model(n).Where("id = ?", maxID).Select(); err != nil { + return nil, err + } + q = q.Where("created_at < ?", n.CreatedAt) + } + + if limit != 0 { + q = q.Limit(limit) + } + + q = q.Order("created_at DESC") + + if err := q.Select(); err != nil { + if err != pg.ErrNoRows { + return nil, err + } + + } + return notifications, nil +} + /* CONVERSION FUNCTIONS */ diff --git a/internal/federation/federating_db.go b/internal/federation/federating_db.go deleted file mode 100644 index 6ae4dc083..000000000 --- a/internal/federation/federating_db.go +++ /dev/null @@ -1,1052 +0,0 @@ -/* - GoToSocial - Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -package federation - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/url" - "sync" - - "github.com/go-fed/activity/pub" - "github.com/go-fed/activity/streams" - "github.com/go-fed/activity/streams/vocab" - "github.com/google/uuid" - "github.com/sirupsen/logrus" - "github.com/superseriousbusiness/gotosocial/internal/config" - "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" - "github.com/superseriousbusiness/gotosocial/internal/typeutils" - "github.com/superseriousbusiness/gotosocial/internal/util" -) - -type FederatingDB interface { - pub.Database - Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error - Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error -} - -// FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface. -// It doesn't care what the underlying implementation of the DB interface is, as long as it works. -type federatingDB struct { - locks *sync.Map - db db.DB - config *config.Config - log *logrus.Logger - typeConverter typeutils.TypeConverter -} - -// NewFederatingDB returns a FederatingDB interface using the given database, config, and logger. -func NewFederatingDB(db db.DB, config *config.Config, log *logrus.Logger) FederatingDB { - return &federatingDB{ - locks: new(sync.Map), - db: db, - config: config, - log: log, - typeConverter: typeutils.NewConverter(config, db), - } -} - -/* - GO-FED DB INTERFACE-IMPLEMENTING FUNCTIONS -*/ - -// Lock takes a lock for the object at the specified id. If an error -// is returned, the lock must not have been taken. -// -// The lock must be able to succeed for an id that does not exist in -// the database. This means acquiring the lock does not guarantee the -// entry exists in the database. -// -// Locks are encouraged to be lightweight and in the Go layer, as some -// processes require tight loops acquiring and releasing locks. -// -// Used to ensure race conditions in multiple requests do not occur. -func (f *federatingDB) Lock(c context.Context, id *url.URL) error { - // Before any other Database methods are called, the relevant `id` - // entries are locked to allow for fine-grained concurrency. - - // Strategy: create a new lock, if stored, continue. Otherwise, lock the - // existing mutex. - mu := &sync.Mutex{} - mu.Lock() // Optimistically lock if we do store it. - i, loaded := f.locks.LoadOrStore(id.String(), mu) - if loaded { - mu = i.(*sync.Mutex) - mu.Lock() - } - return nil -} - -// Unlock makes the lock for the object at the specified id available. -// If an error is returned, the lock must have still been freed. -// -// Used to ensure race conditions in multiple requests do not occur. -func (f *federatingDB) Unlock(c context.Context, id *url.URL) error { - // Once Go-Fed is done calling Database methods, the relevant `id` - // entries are unlocked. - - i, ok := f.locks.Load(id.String()) - if !ok { - return errors.New("missing an id in unlock") - } - mu := i.(*sync.Mutex) - mu.Unlock() - return nil -} - -// InboxContains returns true if the OrderedCollection at 'inbox' -// contains the specified 'id'. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) InboxContains(c context.Context, inbox, id *url.URL) (contains bool, err error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "InboxContains", - "id": id.String(), - }, - ) - l.Debugf("entering INBOXCONTAINS function with for inbox %s and id %s", inbox.String(), id.String()) - - if !util.IsInboxPath(inbox) { - return false, fmt.Errorf("%s is not an inbox URI", inbox.String()) - } - - activityI := c.Value(util.APActivity) - if activityI == nil { - return false, fmt.Errorf("no activity was set for id %s", id.String()) - } - activity, ok := activityI.(pub.Activity) - if !ok || activity == nil { - return false, fmt.Errorf("could not parse contextual activity for id %s", id.String()) - } - - l.Debugf("activity type %s for id %s", activity.GetTypeName(), id.String()) - - return false, nil - - // if err := f.db.GetByID(statusID, >smodel.Status{}); err != nil { - // if _, ok := err.(db.ErrNoEntries); ok { - // // we don't have it - // return false, nil - // } - // // actual error - // return false, fmt.Errorf("error getting status from db: %s", err) - // } - - // // we must have it - // return true, nil -} - -// GetInbox returns the first ordered collection page of the outbox at -// the specified IRI, for prepending new items. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) GetInbox(c context.Context, inboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "GetInbox", - }, - ) - l.Debugf("entering GETINBOX function with inboxIRI %s", inboxIRI.String()) - return streams.NewActivityStreamsOrderedCollectionPage(), nil -} - -// SetInbox saves the inbox value given from GetInbox, with new items -// prepended. Note that the new items must not be added as independent -// database entries. Separate calls to Create will do that. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) SetInbox(c context.Context, inbox vocab.ActivityStreamsOrderedCollectionPage) error { - l := f.log.WithFields( - logrus.Fields{ - "func": "SetInbox", - }, - ) - l.Debug("entering SETINBOX function") - return nil -} - -// Owns returns true if the IRI belongs to this instance, and if -// the database has an entry for the IRI. -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "Owns", - "id": id.String(), - }, - ) - l.Debugf("entering OWNS function with id %s", id.String()) - - // if the id host isn't this instance host, we don't own this IRI - if id.Host != f.config.Host { - l.Debugf("we DO NOT own activity because the host is %s not %s", id.Host, f.config.Host) - return false, nil - } - - // apparently it belongs to this host, so what *is* it? - - // check if it's a status, eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS - if util.IsStatusesPath(id) { - _, uid, err := util.ParseStatusesPath(id) - if err != nil { - return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) - } - if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: uid}}, >smodel.Status{}); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - // there are no entries for this status - return false, nil - } - // an actual error happened - return false, fmt.Errorf("database error fetching status with id %s: %s", uid, err) - } - l.Debug("we DO own this") - return true, nil - } - - // check if it's a user, eg /users/example_username - if util.IsUserPath(id) { - username, err := util.ParseUserPath(id) - if err != nil { - return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) - } - if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - // there are no entries for this username - return false, nil - } - // an actual error happened - return false, fmt.Errorf("database error fetching account with username %s: %s", username, err) - } - l.Debug("we DO own this") - return true, nil - } - - if util.IsFollowersPath(id) { - username, err := util.ParseFollowersPath(id) - if err != nil { - return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) - } - if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - // there are no entries for this username - return false, nil - } - // an actual error happened - return false, fmt.Errorf("database error fetching account with username %s: %s", username, err) - } - l.Debug("we DO own this") - return true, nil - } - - if util.IsFollowingPath(id) { - username, err := util.ParseFollowingPath(id) - if err != nil { - return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) - } - if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - // there are no entries for this username - return false, nil - } - // an actual error happened - return false, fmt.Errorf("database error fetching account with username %s: %s", username, err) - } - l.Debug("we DO own this") - return true, nil - } - - return false, fmt.Errorf("could not match activityID: %s", id.String()) -} - -// ActorForOutbox fetches the actor's IRI for the given outbox IRI. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) ActorForOutbox(c context.Context, outboxIRI *url.URL) (actorIRI *url.URL, err error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "ActorForOutbox", - "inboxIRI": outboxIRI.String(), - }, - ) - l.Debugf("entering ACTORFOROUTBOX function with outboxIRI %s", outboxIRI.String()) - - if !util.IsOutboxPath(outboxIRI) { - return nil, fmt.Errorf("%s is not an outbox URI", outboxIRI.String()) - } - acct := >smodel.Account{} - if err := f.db.GetWhere([]db.Where{{Key: "outbox_uri", Value: outboxIRI.String()}}, acct); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return nil, fmt.Errorf("no actor found that corresponds to outbox %s", outboxIRI.String()) - } - return nil, fmt.Errorf("db error searching for actor with outbox %s", outboxIRI.String()) - } - return url.Parse(acct.URI) -} - -// ActorForInbox fetches the actor's IRI for the given outbox IRI. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (actorIRI *url.URL, err error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "ActorForInbox", - "inboxIRI": inboxIRI.String(), - }, - ) - l.Debugf("entering ACTORFORINBOX function with inboxIRI %s", inboxIRI.String()) - - if !util.IsInboxPath(inboxIRI) { - return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String()) - } - acct := >smodel.Account{} - if err := f.db.GetWhere([]db.Where{{Key: "inbox_uri", Value: inboxIRI.String()}}, acct); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String()) - } - return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String()) - } - return url.Parse(acct.URI) -} - -// OutboxForInbox fetches the corresponding actor's outbox IRI for the -// actor's inbox IRI. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) OutboxForInbox(c context.Context, inboxIRI *url.URL) (outboxIRI *url.URL, err error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "OutboxForInbox", - "inboxIRI": inboxIRI.String(), - }, - ) - l.Debugf("entering OUTBOXFORINBOX function with inboxIRI %s", inboxIRI.String()) - - if !util.IsInboxPath(inboxIRI) { - return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String()) - } - acct := >smodel.Account{} - if err := f.db.GetWhere([]db.Where{{Key: "inbox_uri", Value: inboxIRI.String()}}, acct); err != nil { - if _, ok := err.(db.ErrNoEntries); ok { - return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String()) - } - return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String()) - } - return url.Parse(acct.OutboxURI) -} - -// Exists returns true if the database has an entry for the specified -// id. It may not be owned by this application instance. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Exists(c context.Context, id *url.URL) (exists bool, err error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "Exists", - "id": id.String(), - }, - ) - l.Debugf("entering EXISTS function with id %s", id.String()) - - return false, nil -} - -// Get returns the database entry for the specified id. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Get(c context.Context, id *url.URL) (value vocab.Type, err error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "Get", - "id": id.String(), - }, - ) - l.Debug("entering GET function") - - if util.IsUserPath(id) { - acct := >smodel.Account{} - if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: id.String()}}, acct); err != nil { - return nil, err - } - l.Debug("is user path! returning account") - return f.typeConverter.AccountToAS(acct) - } - - return nil, nil -} - -// Create adds a new entry to the database which must be able to be -// keyed by its id. -// -// Note that Activity values received from federated peers may also be -// created in the database this way if the Federating Protocol is -// enabled. The client may freely decide to store only the id instead of -// the entire value. -// -// The library makes this call only after acquiring a lock first. -// -// Under certain conditions and network activities, Create may be called -// multiple times for the same ActivityStreams object. -func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { - l := f.log.WithFields( - logrus.Fields{ - "func": "Create", - "asType": asType.GetTypeName(), - }, - ) - m, err := streams.Serialize(asType) - if err != nil { - return err - } - b, err := json.Marshal(m) - if err != nil { - return err - } - - l.Debugf("received CREATE asType %s", string(b)) - - targetAcctI := ctx.Value(util.APAccount) - if targetAcctI == nil { - l.Error("target account wasn't set on context") - return nil - } - targetAcct, ok := targetAcctI.(*gtsmodel.Account) - if !ok { - l.Error("target account was set on context but couldn't be parsed") - return nil - } - - fromFederatorChanI := ctx.Value(util.APFromFederatorChanKey) - if fromFederatorChanI == nil { - l.Error("from federator channel wasn't set on context") - return nil - } - fromFederatorChan, ok := fromFederatorChanI.(chan gtsmodel.FromFederator) - if !ok { - l.Error("from federator channel was set on context but couldn't be parsed") - return nil - } - - switch asType.GetTypeName() { - case gtsmodel.ActivityStreamsCreate: - create, ok := asType.(vocab.ActivityStreamsCreate) - if !ok { - return errors.New("could not convert type to create") - } - object := create.GetActivityStreamsObject() - for objectIter := object.Begin(); objectIter != object.End(); objectIter = objectIter.Next() { - switch objectIter.GetType().GetTypeName() { - case gtsmodel.ActivityStreamsNote: - note := objectIter.GetActivityStreamsNote() - status, err := f.typeConverter.ASStatusToStatus(note) - if err != nil { - return fmt.Errorf("error converting note to status: %s", err) - } - if err := f.db.Put(status); err != nil { - if _, ok := err.(db.ErrAlreadyExists); ok { - return nil - } - return fmt.Errorf("database error inserting status: %s", err) - } - - fromFederatorChan <- gtsmodel.FromFederator{ - APObjectType: gtsmodel.ActivityStreamsNote, - APActivityType: gtsmodel.ActivityStreamsCreate, - GTSModel: status, - ReceivingAccount: targetAcct, - } - } - } - case gtsmodel.ActivityStreamsFollow: - follow, ok := asType.(vocab.ActivityStreamsFollow) - if !ok { - return errors.New("could not convert type to follow") - } - - followRequest, err := f.typeConverter.ASFollowToFollowRequest(follow) - if err != nil { - return fmt.Errorf("could not convert Follow to follow request: %s", err) - } - - if err := f.db.Put(followRequest); err != nil { - return fmt.Errorf("database error inserting follow request: %s", err) - } - - if !targetAcct.Locked { - if _, err := f.db.AcceptFollowRequest(followRequest.AccountID, followRequest.TargetAccountID); err != nil { - return fmt.Errorf("database error accepting follow request: %s", err) - } - } - } - return nil -} - -// Update sets an existing entry to the database based on the value's -// id. -// -// Note that Activity values received from federated peers may also be -// updated in the database this way if the Federating Protocol is -// enabled. The client may freely decide to store only the id instead of -// the entire value. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error { - l := f.log.WithFields( - logrus.Fields{ - "func": "Update", - "asType": asType.GetTypeName(), - }, - ) - m, err := streams.Serialize(asType) - if err != nil { - return err - } - b, err := json.Marshal(m) - if err != nil { - return err - } - - l.Debugf("received UPDATE asType %s", string(b)) - - receivingAcctI := ctx.Value(util.APAccount) - if receivingAcctI == nil { - l.Error("receiving account wasn't set on context") - } - receivingAcct, ok := receivingAcctI.(*gtsmodel.Account) - if !ok { - l.Error("receiving account was set on context but couldn't be parsed") - } - - requestingAcctI := ctx.Value(util.APRequestingAccount) - if receivingAcctI == nil { - l.Error("requesting account wasn't set on context") - } - requestingAcct, ok := requestingAcctI.(*gtsmodel.Account) - if !ok { - l.Error("requesting account was set on context but couldn't be parsed") - } - - fromFederatorChanI := ctx.Value(util.APFromFederatorChanKey) - if fromFederatorChanI == nil { - l.Error("from federator channel wasn't set on context") - } - fromFederatorChan, ok := fromFederatorChanI.(chan gtsmodel.FromFederator) - if !ok { - l.Error("from federator channel was set on context but couldn't be parsed") - } - - typeName := asType.GetTypeName() - if typeName == gtsmodel.ActivityStreamsApplication || - typeName == gtsmodel.ActivityStreamsGroup || - typeName == gtsmodel.ActivityStreamsOrganization || - typeName == gtsmodel.ActivityStreamsPerson || - typeName == gtsmodel.ActivityStreamsService { - // it's an UPDATE to some kind of account - var accountable typeutils.Accountable - - switch asType.GetTypeName() { - case gtsmodel.ActivityStreamsApplication: - l.Debug("got update for APPLICATION") - i, ok := asType.(vocab.ActivityStreamsApplication) - if !ok { - return errors.New("could not convert type to application") - } - accountable = i - case gtsmodel.ActivityStreamsGroup: - l.Debug("got update for GROUP") - i, ok := asType.(vocab.ActivityStreamsGroup) - if !ok { - return errors.New("could not convert type to group") - } - accountable = i - case gtsmodel.ActivityStreamsOrganization: - l.Debug("got update for ORGANIZATION") - i, ok := asType.(vocab.ActivityStreamsOrganization) - if !ok { - return errors.New("could not convert type to organization") - } - accountable = i - case gtsmodel.ActivityStreamsPerson: - l.Debug("got update for PERSON") - i, ok := asType.(vocab.ActivityStreamsPerson) - if !ok { - return errors.New("could not convert type to person") - } - accountable = i - case gtsmodel.ActivityStreamsService: - l.Debug("got update for SERVICE") - i, ok := asType.(vocab.ActivityStreamsService) - if !ok { - return errors.New("could not convert type to service") - } - accountable = i - } - - updatedAcct, err := f.typeConverter.ASRepresentationToAccount(accountable, true) - if err != nil { - return fmt.Errorf("error converting to account: %s", err) - } - - if requestingAcct.URI != updatedAcct.URI { - return fmt.Errorf("update for account %s was requested by account %s, this is not valid", updatedAcct.URI, requestingAcct.URI) - } - - updatedAcct.ID = requestingAcct.ID // set this here so the db will update properly instead of trying to PUT this and getting constraint issues - if err := f.db.UpdateByID(requestingAcct.ID, updatedAcct); err != nil { - return fmt.Errorf("database error inserting updated account: %s", err) - } - - fromFederatorChan <- gtsmodel.FromFederator{ - APObjectType: gtsmodel.ActivityStreamsProfile, - APActivityType: gtsmodel.ActivityStreamsUpdate, - GTSModel: updatedAcct, - ReceivingAccount: receivingAcct, - } - - } - - return nil -} - -// Delete removes the entry with the given id. -// -// Delete is only called for federated objects. Deletes from the Social -// Protocol instead call Update to create a Tombstone. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Delete(ctx context.Context, id *url.URL) error { - l := f.log.WithFields( - logrus.Fields{ - "func": "Delete", - "id": id.String(), - }, - ) - l.Debugf("received DELETE id %s", id.String()) - - inboxAcctI := ctx.Value(util.APAccount) - if inboxAcctI == nil { - l.Error("inbox account wasn't set on context") - return nil - } - inboxAcct, ok := inboxAcctI.(*gtsmodel.Account) - if !ok { - l.Error("inbox account was set on context but couldn't be parsed") - return nil - } - - fromFederatorChanI := ctx.Value(util.APFromFederatorChanKey) - if fromFederatorChanI == nil { - l.Error("from federator channel wasn't set on context") - return nil - } - fromFederatorChan, ok := fromFederatorChanI.(chan gtsmodel.FromFederator) - if !ok { - l.Error("from federator channel was set on context but couldn't be parsed") - return nil - } - - // in a delete we only get the URI, we can't know if we have a status or a profile or something else, - // so we have to try a few different things... - where := []db.Where{{Key: "uri", Value: id.String()}} - - s := >smodel.Status{} - if err := f.db.GetWhere(where, s); err == nil { - // it's a status - l.Debugf("uri is for status with id: %s", s.ID) - if err := f.db.DeleteByID(s.ID, >smodel.Status{}); err != nil { - return fmt.Errorf("Delete: err deleting status: %s", err) - } - fromFederatorChan <- gtsmodel.FromFederator{ - APObjectType: gtsmodel.ActivityStreamsNote, - APActivityType: gtsmodel.ActivityStreamsDelete, - GTSModel: s, - ReceivingAccount: inboxAcct, - } - } - - a := >smodel.Account{} - if err := f.db.GetWhere(where, a); err == nil { - // it's an account - l.Debugf("uri is for an account with id: %s", s.ID) - if err := f.db.DeleteByID(a.ID, >smodel.Account{}); err != nil { - return fmt.Errorf("Delete: err deleting account: %s", err) - } - fromFederatorChan <- gtsmodel.FromFederator{ - APObjectType: gtsmodel.ActivityStreamsProfile, - APActivityType: gtsmodel.ActivityStreamsDelete, - GTSModel: a, - ReceivingAccount: inboxAcct, - } - } - - return nil -} - -// GetOutbox returns the first ordered collection page of the outbox -// at the specified IRI, for prepending new items. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) GetOutbox(c context.Context, outboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "GetOutbox", - }, - ) - l.Debug("entering GETOUTBOX function") - - return streams.NewActivityStreamsOrderedCollectionPage(), nil -} - -// SetOutbox saves the outbox value given from GetOutbox, with new items -// prepended. Note that the new items must not be added as independent -// database entries. Separate calls to Create will do that. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) SetOutbox(c context.Context, outbox vocab.ActivityStreamsOrderedCollectionPage) error { - l := f.log.WithFields( - logrus.Fields{ - "func": "SetOutbox", - }, - ) - l.Debug("entering SETOUTBOX function") - - return nil -} - -// NewID creates a new IRI id for the provided activity or object. The -// implementation does not need to set the 'id' property and simply -// needs to determine the value. -// -// The go-fed library will handle setting the 'id' property on the -// activity or object provided with the value returned. -func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "NewID", - "asType": t.GetTypeName(), - }, - ) - m, err := streams.Serialize(t) - if err != nil { - return nil, err - } - b, err := json.Marshal(m) - if err != nil { - return nil, err - } - l.Debugf("received NEWID request for asType %s", string(b)) - - switch t.GetTypeName() { - case gtsmodel.ActivityStreamsFollow: - // FOLLOW - // ID might already be set on a follow we've created, so check it here and return it if it is - follow, ok := t.(vocab.ActivityStreamsFollow) - if !ok { - return nil, errors.New("newid: follow couldn't be parsed into vocab.ActivityStreamsFollow") - } - idProp := follow.GetJSONLDId() - if idProp != nil { - if idProp.IsIRI() { - return idProp.GetIRI(), nil - } - } - // it's not set so create one based on the actor set on the follow (ie., the followER not the followEE) - actorProp := follow.GetActivityStreamsActor() - if actorProp != nil { - for iter := actorProp.Begin(); iter != actorProp.End(); iter = iter.Next() { - // take the IRI of the first actor we can find (there should only be one) - if iter.IsIRI() { - actorAccount := >smodel.Account{} - if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: iter.GetIRI().String()}}, actorAccount); err == nil { // if there's an error here, just use the fallback behavior -- we don't need to return an error here - return url.Parse(util.GenerateURIForFollow(actorAccount.Username, f.config.Protocol, f.config.Host, uuid.NewString())) - } - } - } - } - case gtsmodel.ActivityStreamsNote: - // NOTE aka STATUS - // ID might already be set on a note we've created, so check it here and return it if it is - note, ok := t.(vocab.ActivityStreamsNote) - if !ok { - return nil, errors.New("newid: note couldn't be parsed into vocab.ActivityStreamsNote") - } - idProp := note.GetJSONLDId() - if idProp != nil { - if idProp.IsIRI() { - return idProp.GetIRI(), nil - } - } - case gtsmodel.ActivityStreamsLike: - // LIKE aka FAVE - // ID might already be set on a fave we've created, so check it here and return it if it is - fave, ok := t.(vocab.ActivityStreamsLike) - if !ok { - return nil, errors.New("newid: fave couldn't be parsed into vocab.ActivityStreamsLike") - } - idProp := fave.GetJSONLDId() - if idProp != nil { - if idProp.IsIRI() { - return idProp.GetIRI(), nil - } - } - } - - // fallback default behavior: just return a random UUID after our protocol and host - return url.Parse(fmt.Sprintf("%s://%s/%s", f.config.Protocol, f.config.Host, uuid.NewString())) -} - -// Followers obtains the Followers Collection for an actor with the -// given id. -// -// If modified, the library will then call Update. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Followers(c context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "Followers", - "actorIRI": actorIRI.String(), - }, - ) - l.Debugf("entering FOLLOWERS function with actorIRI %s", actorIRI.String()) - - acct := >smodel.Account{} - if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: actorIRI.String()}}, acct); err != nil { - return nil, fmt.Errorf("db error getting account with uri %s: %s", actorIRI.String(), err) - } - - acctFollowers := []gtsmodel.Follow{} - if err := f.db.GetFollowersByAccountID(acct.ID, &acctFollowers); err != nil { - return nil, fmt.Errorf("db error getting followers for account id %s: %s", acct.ID, err) - } - - followers = streams.NewActivityStreamsCollection() - items := streams.NewActivityStreamsItemsProperty() - for _, follow := range acctFollowers { - gtsFollower := >smodel.Account{} - if err := f.db.GetByID(follow.AccountID, gtsFollower); err != nil { - return nil, fmt.Errorf("db error getting account id %s: %s", follow.AccountID, err) - } - uri, err := url.Parse(gtsFollower.URI) - if err != nil { - return nil, fmt.Errorf("error parsing %s as url: %s", gtsFollower.URI, err) - } - items.AppendIRI(uri) - } - followers.SetActivityStreamsItems(items) - return -} - -// Following obtains the Following Collection for an actor with the -// given id. -// -// If modified, the library will then call Update. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Following(c context.Context, actorIRI *url.URL) (following vocab.ActivityStreamsCollection, err error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "Following", - "actorIRI": actorIRI.String(), - }, - ) - l.Debugf("entering FOLLOWING function with actorIRI %s", actorIRI.String()) - - acct := >smodel.Account{} - if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: actorIRI.String()}}, acct); err != nil { - return nil, fmt.Errorf("db error getting account with uri %s: %s", actorIRI.String(), err) - } - - acctFollowing := []gtsmodel.Follow{} - if err := f.db.GetFollowingByAccountID(acct.ID, &acctFollowing); err != nil { - return nil, fmt.Errorf("db error getting following for account id %s: %s", acct.ID, err) - } - - following = streams.NewActivityStreamsCollection() - items := streams.NewActivityStreamsItemsProperty() - for _, follow := range acctFollowing { - gtsFollowing := >smodel.Account{} - if err := f.db.GetByID(follow.AccountID, gtsFollowing); err != nil { - return nil, fmt.Errorf("db error getting account id %s: %s", follow.AccountID, err) - } - uri, err := url.Parse(gtsFollowing.URI) - if err != nil { - return nil, fmt.Errorf("error parsing %s as url: %s", gtsFollowing.URI, err) - } - items.AppendIRI(uri) - } - following.SetActivityStreamsItems(items) - return -} - -// Liked obtains the Liked Collection for an actor with the -// given id. -// -// If modified, the library will then call Update. -// -// The library makes this call only after acquiring a lock first. -func (f *federatingDB) Liked(c context.Context, actorIRI *url.URL) (liked vocab.ActivityStreamsCollection, err error) { - l := f.log.WithFields( - logrus.Fields{ - "func": "Liked", - "actorIRI": actorIRI.String(), - }, - ) - l.Debugf("entering LIKED function with actorIRI %s", actorIRI.String()) - return nil, nil -} - -/* - CUSTOM FUNCTIONALITY FOR GTS -*/ - -func (f *federatingDB) Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error { - l := f.log.WithFields( - logrus.Fields{ - "func": "Undo", - "asType": undo.GetTypeName(), - }, - ) - m, err := streams.Serialize(undo) - if err != nil { - return err - } - b, err := json.Marshal(m) - if err != nil { - return err - } - l.Debugf("received UNDO asType %s", string(b)) - - targetAcctI := ctx.Value(util.APAccount) - if targetAcctI == nil { - l.Error("UNDO: target account wasn't set on context") - return nil - } - targetAcct, ok := targetAcctI.(*gtsmodel.Account) - if !ok { - l.Error("UNDO: target account was set on context but couldn't be parsed") - return nil - } - - undoObject := undo.GetActivityStreamsObject() - if undoObject == nil { - return errors.New("UNDO: no object set on vocab.ActivityStreamsUndo") - } - - for iter := undoObject.Begin(); iter != undoObject.End(); iter = iter.Next() { - switch iter.GetType().GetTypeName() { - case string(gtsmodel.ActivityStreamsFollow): - // UNDO FOLLOW - ASFollow, ok := iter.GetType().(vocab.ActivityStreamsFollow) - if !ok { - return errors.New("UNDO: couldn't parse follow into vocab.ActivityStreamsFollow") - } - // make sure the actor owns the follow - if !sameActor(undo.GetActivityStreamsActor(), ASFollow.GetActivityStreamsActor()) { - return errors.New("UNDO: follow actor and activity actor not the same") - } - // convert the follow to something we can understand - gtsFollow, err := f.typeConverter.ASFollowToFollow(ASFollow) - if err != nil { - return fmt.Errorf("UNDO: error converting asfollow to gtsfollow: %s", err) - } - // make sure the addressee of the original follow is the same as whatever inbox this landed in - if gtsFollow.TargetAccountID != targetAcct.ID { - return errors.New("UNDO: follow object account and inbox account were not the same") - } - // delete any existing FOLLOW - if err := f.db.DeleteWhere([]db.Where{{Key: "uri", Value: gtsFollow.URI}}, >smodel.Follow{}); err != nil { - return fmt.Errorf("UNDO: db error removing follow: %s", err) - } - // delete any existing FOLLOW REQUEST - if err := f.db.DeleteWhere([]db.Where{{Key: "uri", Value: gtsFollow.URI}}, >smodel.FollowRequest{}); err != nil { - return fmt.Errorf("UNDO: db error removing follow request: %s", err) - } - l.Debug("follow undone") - return nil - case string(gtsmodel.ActivityStreamsLike): - // UNDO LIKE - case string(gtsmodel.ActivityStreamsAnnounce): - // UNDO BOOST/REBLOG/ANNOUNCE - } - } - - return nil -} - -func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error { - l := f.log.WithFields( - logrus.Fields{ - "func": "Accept", - "asType": accept.GetTypeName(), - }, - ) - m, err := streams.Serialize(accept) - if err != nil { - return err - } - b, err := json.Marshal(m) - if err != nil { - return err - } - l.Debugf("received ACCEPT asType %s", string(b)) - - inboxAcctI := ctx.Value(util.APAccount) - if inboxAcctI == nil { - l.Error("ACCEPT: inbox account wasn't set on context") - return nil - } - inboxAcct, ok := inboxAcctI.(*gtsmodel.Account) - if !ok { - l.Error("ACCEPT: inbox account was set on context but couldn't be parsed") - return nil - } - - acceptObject := accept.GetActivityStreamsObject() - if acceptObject == nil { - return errors.New("ACCEPT: no object set on vocab.ActivityStreamsUndo") - } - - for iter := acceptObject.Begin(); iter != acceptObject.End(); iter = iter.Next() { - switch iter.GetType().GetTypeName() { - case string(gtsmodel.ActivityStreamsFollow): - // ACCEPT FOLLOW - asFollow, ok := iter.GetType().(vocab.ActivityStreamsFollow) - if !ok { - return errors.New("ACCEPT: couldn't parse follow into vocab.ActivityStreamsFollow") - } - // convert the follow to something we can understand - gtsFollow, err := f.typeConverter.ASFollowToFollow(asFollow) - if err != nil { - return fmt.Errorf("ACCEPT: error converting asfollow to gtsfollow: %s", err) - } - // make sure the addressee of the original follow is the same as whatever inbox this landed in - if gtsFollow.AccountID != inboxAcct.ID { - return errors.New("ACCEPT: follow object account and inbox account were not the same") - } - _, err = f.db.AcceptFollowRequest(gtsFollow.AccountID, gtsFollow.TargetAccountID) - return err - } - } - - return nil -} diff --git a/internal/federation/federatingdb/accept.go b/internal/federation/federatingdb/accept.go new file mode 100644 index 000000000..d08544c99 --- /dev/null +++ b/internal/federation/federatingdb/accept.go @@ -0,0 +1,94 @@ +package federatingdb + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "Accept", + "asType": accept.GetTypeName(), + }, + ) + m, err := streams.Serialize(accept) + if err != nil { + return err + } + b, err := json.Marshal(m) + if err != nil { + return err + } + l.Debugf("received ACCEPT asType %s", string(b)) + + fromFederatorChanI := ctx.Value(util.APFromFederatorChanKey) + if fromFederatorChanI == nil { + l.Error("ACCEPT: from federator channel wasn't set on context") + return nil + } + fromFederatorChan, ok := fromFederatorChanI.(chan gtsmodel.FromFederator) + if !ok { + l.Error("ACCEPT: from federator channel was set on context but couldn't be parsed") + return nil + } + + inboxAcctI := ctx.Value(util.APAccount) + if inboxAcctI == nil { + l.Error("ACCEPT: inbox account wasn't set on context") + return nil + } + inboxAcct, ok := inboxAcctI.(*gtsmodel.Account) + if !ok { + l.Error("ACCEPT: inbox account was set on context but couldn't be parsed") + return nil + } + + acceptObject := accept.GetActivityStreamsObject() + if acceptObject == nil { + return errors.New("ACCEPT: no object set on vocab.ActivityStreamsAccept") + } + + for iter := acceptObject.Begin(); iter != acceptObject.End(); iter = iter.Next() { + switch iter.GetType().GetTypeName() { + case string(gtsmodel.ActivityStreamsFollow): + // ACCEPT FOLLOW + asFollow, ok := iter.GetType().(vocab.ActivityStreamsFollow) + if !ok { + return errors.New("ACCEPT: couldn't parse follow into vocab.ActivityStreamsFollow") + } + // convert the follow to something we can understand + gtsFollow, err := f.typeConverter.ASFollowToFollow(asFollow) + if err != nil { + return fmt.Errorf("ACCEPT: error converting asfollow to gtsfollow: %s", err) + } + // make sure the addressee of the original follow is the same as whatever inbox this landed in + if gtsFollow.AccountID != inboxAcct.ID { + return errors.New("ACCEPT: follow object account and inbox account were not the same") + } + follow, err := f.db.AcceptFollowRequest(gtsFollow.AccountID, gtsFollow.TargetAccountID) + if err != nil { + return err + } + + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsFollow, + APActivityType: gtsmodel.ActivityStreamsAccept, + GTSModel: follow, + ReceivingAccount: inboxAcct, + } + + return nil + } + } + + return nil +} diff --git a/internal/federation/federatingdb/create.go b/internal/federation/federatingdb/create.go new file mode 100644 index 000000000..026674e43 --- /dev/null +++ b/internal/federation/federatingdb/create.go @@ -0,0 +1,161 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package federatingdb + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// Create adds a new entry to the database which must be able to be +// keyed by its id. +// +// Note that Activity values received from federated peers may also be +// created in the database this way if the Federating Protocol is +// enabled. The client may freely decide to store only the id instead of +// the entire value. +// +// The library makes this call only after acquiring a lock first. +// +// Under certain conditions and network activities, Create may be called +// multiple times for the same ActivityStreams object. +func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "Create", + "asType": asType.GetTypeName(), + }, + ) + m, err := streams.Serialize(asType) + if err != nil { + return err + } + b, err := json.Marshal(m) + if err != nil { + return err + } + + l.Debugf("received CREATE asType %s", string(b)) + + targetAcctI := ctx.Value(util.APAccount) + if targetAcctI == nil { + l.Error("target account wasn't set on context") + return nil + } + targetAcct, ok := targetAcctI.(*gtsmodel.Account) + if !ok { + l.Error("target account was set on context but couldn't be parsed") + return nil + } + + fromFederatorChanI := ctx.Value(util.APFromFederatorChanKey) + if fromFederatorChanI == nil { + l.Error("from federator channel wasn't set on context") + return nil + } + fromFederatorChan, ok := fromFederatorChanI.(chan gtsmodel.FromFederator) + if !ok { + l.Error("from federator channel was set on context but couldn't be parsed") + return nil + } + + switch asType.GetTypeName() { + case gtsmodel.ActivityStreamsCreate: + create, ok := asType.(vocab.ActivityStreamsCreate) + if !ok { + return errors.New("could not convert type to create") + } + object := create.GetActivityStreamsObject() + for objectIter := object.Begin(); objectIter != object.End(); objectIter = objectIter.Next() { + switch objectIter.GetType().GetTypeName() { + case gtsmodel.ActivityStreamsNote: + note := objectIter.GetActivityStreamsNote() + status, err := f.typeConverter.ASStatusToStatus(note) + if err != nil { + return fmt.Errorf("error converting note to status: %s", err) + } + if err := f.db.Put(status); err != nil { + if _, ok := err.(db.ErrAlreadyExists); ok { + return nil + } + return fmt.Errorf("database error inserting status: %s", err) + } + + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsNote, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: status, + ReceivingAccount: targetAcct, + } + } + } + case gtsmodel.ActivityStreamsFollow: + follow, ok := asType.(vocab.ActivityStreamsFollow) + if !ok { + return errors.New("could not convert type to follow") + } + + followRequest, err := f.typeConverter.ASFollowToFollowRequest(follow) + if err != nil { + return fmt.Errorf("could not convert Follow to follow request: %s", err) + } + + if err := f.db.Put(followRequest); err != nil { + return fmt.Errorf("database error inserting follow request: %s", err) + } + + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsFollow, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: followRequest, + ReceivingAccount: targetAcct, + } + case gtsmodel.ActivityStreamsLike: + like, ok := asType.(vocab.ActivityStreamsLike) + if !ok { + return errors.New("could not convert type to like") + } + + fave, err := f.typeConverter.ASLikeToFave(like) + if err != nil { + return fmt.Errorf("could not convert Like to fave: %s", err) + } + + if err := f.db.Put(fave); err != nil { + return fmt.Errorf("database error inserting fave: %s", err) + } + + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsLike, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: fave, + ReceivingAccount: targetAcct, + } + } + return nil +} diff --git a/internal/federation/federatingdb/db.go b/internal/federation/federatingdb/db.go new file mode 100644 index 000000000..b5207fc34 --- /dev/null +++ b/internal/federation/federatingdb/db.go @@ -0,0 +1,59 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package federatingdb + +import ( + "context" + "sync" + + "github.com/go-fed/activity/pub" + "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +// DB wraps the pub.Database interface with a couple of custom functions for GoToSocial. +type DB interface { + pub.Database + Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error + Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error +} + +// FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface. +// It doesn't care what the underlying implementation of the DB interface is, as long as it works. +type federatingDB struct { + locks *sync.Map + db db.DB + config *config.Config + log *logrus.Logger + typeConverter typeutils.TypeConverter +} + +// New returns a DB interface using the given database, config, and logger. +func New(db db.DB, config *config.Config, log *logrus.Logger) DB { + return &federatingDB{ + locks: new(sync.Map), + db: db, + config: config, + log: log, + typeConverter: typeutils.NewConverter(config, db), + } +} diff --git a/internal/federation/federatingdb/delete.go b/internal/federation/federatingdb/delete.go new file mode 100644 index 000000000..fe32434c4 --- /dev/null +++ b/internal/federation/federatingdb/delete.go @@ -0,0 +1,86 @@ +package federatingdb + +import ( + "context" + "fmt" + "net/url" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// Delete removes the entry with the given id. +// +// Delete is only called for federated objects. Deletes from the Social +// Protocol instead call Update to create a Tombstone. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Delete(ctx context.Context, id *url.URL) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "Delete", + "id": id.String(), + }, + ) + l.Debugf("received DELETE id %s", id.String()) + + inboxAcctI := ctx.Value(util.APAccount) + if inboxAcctI == nil { + l.Error("inbox account wasn't set on context") + return nil + } + inboxAcct, ok := inboxAcctI.(*gtsmodel.Account) + if !ok { + l.Error("inbox account was set on context but couldn't be parsed") + return nil + } + + fromFederatorChanI := ctx.Value(util.APFromFederatorChanKey) + if fromFederatorChanI == nil { + l.Error("from federator channel wasn't set on context") + return nil + } + fromFederatorChan, ok := fromFederatorChanI.(chan gtsmodel.FromFederator) + if !ok { + l.Error("from federator channel was set on context but couldn't be parsed") + return nil + } + + // in a delete we only get the URI, we can't know if we have a status or a profile or something else, + // so we have to try a few different things... + where := []db.Where{{Key: "uri", Value: id.String()}} + + s := >smodel.Status{} + if err := f.db.GetWhere(where, s); err == nil { + // it's a status + l.Debugf("uri is for status with id: %s", s.ID) + if err := f.db.DeleteByID(s.ID, >smodel.Status{}); err != nil { + return fmt.Errorf("Delete: err deleting status: %s", err) + } + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsNote, + APActivityType: gtsmodel.ActivityStreamsDelete, + GTSModel: s, + ReceivingAccount: inboxAcct, + } + } + + a := >smodel.Account{} + if err := f.db.GetWhere(where, a); err == nil { + // it's an account + l.Debugf("uri is for an account with id: %s", s.ID) + if err := f.db.DeleteByID(a.ID, >smodel.Account{}); err != nil { + return fmt.Errorf("Delete: err deleting account: %s", err) + } + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsProfile, + APActivityType: gtsmodel.ActivityStreamsDelete, + GTSModel: a, + ReceivingAccount: inboxAcct, + } + } + + return nil +} diff --git a/internal/federation/federatingdb/exists.go b/internal/federation/federatingdb/exists.go new file mode 100644 index 000000000..b5c10b895 --- /dev/null +++ b/internal/federation/federatingdb/exists.go @@ -0,0 +1,24 @@ +package federatingdb + +import ( + "context" + "net/url" + + "github.com/sirupsen/logrus" +) + +// Exists returns true if the database has an entry for the specified +// id. It may not be owned by this application instance. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Exists(c context.Context, id *url.URL) (exists bool, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "Exists", + "id": id.String(), + }, + ) + l.Debugf("entering EXISTS function with id %s", id.String()) + + return false, nil +} diff --git a/internal/federation/federatingdb/followers.go b/internal/federation/federatingdb/followers.go new file mode 100644 index 000000000..28f3bb6d1 --- /dev/null +++ b/internal/federation/federatingdb/followers.go @@ -0,0 +1,55 @@ +package federatingdb + +import ( + "context" + "fmt" + "net/url" + + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// Followers obtains the Followers Collection for an actor with the +// given id. +// +// If modified, the library will then call Update. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Followers(c context.Context, actorIRI *url.URL) (followers vocab.ActivityStreamsCollection, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "Followers", + "actorIRI": actorIRI.String(), + }, + ) + l.Debugf("entering FOLLOWERS function with actorIRI %s", actorIRI.String()) + + acct := >smodel.Account{} + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: actorIRI.String()}}, acct); err != nil { + return nil, fmt.Errorf("db error getting account with uri %s: %s", actorIRI.String(), err) + } + + acctFollowers := []gtsmodel.Follow{} + if err := f.db.GetFollowersByAccountID(acct.ID, &acctFollowers); err != nil { + return nil, fmt.Errorf("db error getting followers for account id %s: %s", acct.ID, err) + } + + followers = streams.NewActivityStreamsCollection() + items := streams.NewActivityStreamsItemsProperty() + for _, follow := range acctFollowers { + gtsFollower := >smodel.Account{} + if err := f.db.GetByID(follow.AccountID, gtsFollower); err != nil { + return nil, fmt.Errorf("db error getting account id %s: %s", follow.AccountID, err) + } + uri, err := url.Parse(gtsFollower.URI) + if err != nil { + return nil, fmt.Errorf("error parsing %s as url: %s", gtsFollower.URI, err) + } + items.AppendIRI(uri) + } + followers.SetActivityStreamsItems(items) + return +} diff --git a/internal/federation/federatingdb/following.go b/internal/federation/federatingdb/following.go new file mode 100644 index 000000000..342250880 --- /dev/null +++ b/internal/federation/federatingdb/following.go @@ -0,0 +1,55 @@ +package federatingdb + +import ( + "context" + "fmt" + "net/url" + + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +// Following obtains the Following Collection for an actor with the +// given id. +// +// If modified, the library will then call Update. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Following(c context.Context, actorIRI *url.URL) (following vocab.ActivityStreamsCollection, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "Following", + "actorIRI": actorIRI.String(), + }, + ) + l.Debugf("entering FOLLOWING function with actorIRI %s", actorIRI.String()) + + acct := >smodel.Account{} + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: actorIRI.String()}}, acct); err != nil { + return nil, fmt.Errorf("db error getting account with uri %s: %s", actorIRI.String(), err) + } + + acctFollowing := []gtsmodel.Follow{} + if err := f.db.GetFollowingByAccountID(acct.ID, &acctFollowing); err != nil { + return nil, fmt.Errorf("db error getting following for account id %s: %s", acct.ID, err) + } + + following = streams.NewActivityStreamsCollection() + items := streams.NewActivityStreamsItemsProperty() + for _, follow := range acctFollowing { + gtsFollowing := >smodel.Account{} + if err := f.db.GetByID(follow.AccountID, gtsFollowing); err != nil { + return nil, fmt.Errorf("db error getting account id %s: %s", follow.AccountID, err) + } + uri, err := url.Parse(gtsFollowing.URI) + if err != nil { + return nil, fmt.Errorf("error parsing %s as url: %s", gtsFollowing.URI, err) + } + items.AppendIRI(uri) + } + following.SetActivityStreamsItems(items) + return +} diff --git a/internal/federation/federatingdb/get.go b/internal/federation/federatingdb/get.go new file mode 100644 index 000000000..77a24bf43 --- /dev/null +++ b/internal/federation/federatingdb/get.go @@ -0,0 +1,83 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package federatingdb + +import ( + "context" + "errors" + "net/url" + + "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// Get returns the database entry for the specified id. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Get(c context.Context, id *url.URL) (value vocab.Type, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "Get", + "id": id.String(), + }, + ) + l.Debug("entering GET function") + + if util.IsUserPath(id) { + acct := >smodel.Account{} + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: id.String()}}, acct); err != nil { + return nil, err + } + l.Debug("is user path! returning account") + return f.typeConverter.AccountToAS(acct) + } + + if util.IsFollowersPath(id) { + acct := >smodel.Account{} + if err := f.db.GetWhere([]db.Where{{Key: "followers_uri", Value: id.String()}}, acct); err != nil { + return nil, err + } + + followersURI, err := url.Parse(acct.FollowersURI) + if err != nil { + return nil, err + } + + return f.Followers(c, followersURI) + } + + if util.IsFollowingPath(id) { + acct := >smodel.Account{} + if err := f.db.GetWhere([]db.Where{{Key: "following_uri", Value: id.String()}}, acct); err != nil { + return nil, err + } + + followingURI, err := url.Parse(acct.FollowingURI) + if err != nil { + return nil, err + } + + return f.Following(c, followingURI) + } + + return nil, errors.New("could not get") +} diff --git a/internal/federation/federatingdb/inbox.go b/internal/federation/federatingdb/inbox.go new file mode 100644 index 000000000..25ef2cda5 --- /dev/null +++ b/internal/federation/federatingdb/inbox.go @@ -0,0 +1,73 @@ +package federatingdb + +import ( + "context" + "fmt" + "net/url" + + "github.com/go-fed/activity/pub" + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// InboxContains returns true if the OrderedCollection at 'inbox' +// contains the specified 'id'. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) InboxContains(c context.Context, inbox, id *url.URL) (contains bool, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "InboxContains", + "id": id.String(), + }, + ) + l.Debugf("entering INBOXCONTAINS function with for inbox %s and id %s", inbox.String(), id.String()) + + if !util.IsInboxPath(inbox) { + return false, fmt.Errorf("%s is not an inbox URI", inbox.String()) + } + + activityI := c.Value(util.APActivity) + if activityI == nil { + return false, fmt.Errorf("no activity was set for id %s", id.String()) + } + activity, ok := activityI.(pub.Activity) + if !ok || activity == nil { + return false, fmt.Errorf("could not parse contextual activity for id %s", id.String()) + } + + l.Debugf("activity type %s for id %s", activity.GetTypeName(), id.String()) + + return false, nil +} + +// GetInbox returns the first ordered collection page of the outbox at +// the specified IRI, for prepending new items. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) GetInbox(c context.Context, inboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "GetInbox", + }, + ) + l.Debugf("entering GETINBOX function with inboxIRI %s", inboxIRI.String()) + return streams.NewActivityStreamsOrderedCollectionPage(), nil +} + +// SetInbox saves the inbox value given from GetInbox, with new items +// prepended. Note that the new items must not be added as independent +// database entries. Separate calls to Create will do that. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) SetInbox(c context.Context, inbox vocab.ActivityStreamsOrderedCollectionPage) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "SetInbox", + }, + ) + l.Debug("entering SETINBOX function") + return nil +} diff --git a/internal/federation/federatingdb/liked.go b/internal/federation/federatingdb/liked.go new file mode 100644 index 000000000..5645d6f1e --- /dev/null +++ b/internal/federation/federatingdb/liked.go @@ -0,0 +1,26 @@ +package federatingdb + +import ( + "context" + "net/url" + + "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" +) + +// Liked obtains the Liked Collection for an actor with the +// given id. +// +// If modified, the library will then call Update. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Liked(c context.Context, actorIRI *url.URL) (liked vocab.ActivityStreamsCollection, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "Liked", + "actorIRI": actorIRI.String(), + }, + ) + l.Debugf("entering LIKED function with actorIRI %s", actorIRI.String()) + return nil, nil +} diff --git a/internal/federation/federatingdb/lock.go b/internal/federation/federatingdb/lock.go new file mode 100644 index 000000000..417fd79b2 --- /dev/null +++ b/internal/federation/federatingdb/lock.go @@ -0,0 +1,70 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package federatingdb + +import ( + "context" + "errors" + "net/url" + "sync" +) + +// Lock takes a lock for the object at the specified id. If an error +// is returned, the lock must not have been taken. +// +// The lock must be able to succeed for an id that does not exist in +// the database. This means acquiring the lock does not guarantee the +// entry exists in the database. +// +// Locks are encouraged to be lightweight and in the Go layer, as some +// processes require tight loops acquiring and releasing locks. +// +// Used to ensure race conditions in multiple requests do not occur. +func (f *federatingDB) Lock(c context.Context, id *url.URL) error { + // Before any other Database methods are called, the relevant `id` + // entries are locked to allow for fine-grained concurrency. + + // Strategy: create a new lock, if stored, continue. Otherwise, lock the + // existing mutex. + mu := &sync.Mutex{} + mu.Lock() // Optimistically lock if we do store it. + i, loaded := f.locks.LoadOrStore(id.String(), mu) + if loaded { + mu = i.(*sync.Mutex) + mu.Lock() + } + return nil +} + +// Unlock makes the lock for the object at the specified id available. +// If an error is returned, the lock must have still been freed. +// +// Used to ensure race conditions in multiple requests do not occur. +func (f *federatingDB) Unlock(c context.Context, id *url.URL) error { + // Once Go-Fed is done calling Database methods, the relevant `id` + // entries are unlocked. + + i, ok := f.locks.Load(id.String()) + if !ok { + return errors.New("missing an id in unlock") + } + mu := i.(*sync.Mutex) + mu.Unlock() + return nil +} diff --git a/internal/federation/federatingdb/outbox.go b/internal/federation/federatingdb/outbox.go new file mode 100644 index 000000000..1568e0017 --- /dev/null +++ b/internal/federation/federatingdb/outbox.go @@ -0,0 +1,71 @@ +package federatingdb + +import ( + "context" + "fmt" + "net/url" + + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// GetOutbox returns the first ordered collection page of the outbox +// at the specified IRI, for prepending new items. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) GetOutbox(c context.Context, outboxIRI *url.URL) (inbox vocab.ActivityStreamsOrderedCollectionPage, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "GetOutbox", + }, + ) + l.Debug("entering GETOUTBOX function") + + return streams.NewActivityStreamsOrderedCollectionPage(), nil +} + +// SetOutbox saves the outbox value given from GetOutbox, with new items +// prepended. Note that the new items must not be added as independent +// database entries. Separate calls to Create will do that. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) SetOutbox(c context.Context, outbox vocab.ActivityStreamsOrderedCollectionPage) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "SetOutbox", + }, + ) + l.Debug("entering SETOUTBOX function") + + return nil +} + +// OutboxForInbox fetches the corresponding actor's outbox IRI for the +// actor's inbox IRI. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) OutboxForInbox(c context.Context, inboxIRI *url.URL) (outboxIRI *url.URL, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "OutboxForInbox", + "inboxIRI": inboxIRI.String(), + }, + ) + l.Debugf("entering OUTBOXFORINBOX function with inboxIRI %s", inboxIRI.String()) + + if !util.IsInboxPath(inboxIRI) { + return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String()) + } + acct := >smodel.Account{} + if err := f.db.GetWhere([]db.Where{{Key: "inbox_uri", Value: inboxIRI.String()}}, acct); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String()) + } + return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String()) + } + return url.Parse(acct.OutboxURI) +} diff --git a/internal/federation/federatingdb/owns.go b/internal/federation/federatingdb/owns.go new file mode 100644 index 000000000..fe7160021 --- /dev/null +++ b/internal/federation/federatingdb/owns.go @@ -0,0 +1,123 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package federatingdb + +import ( + "context" + "fmt" + "net/url" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// Owns returns true if the IRI belongs to this instance, and if +// the database has an entry for the IRI. +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "Owns", + "id": id.String(), + }, + ) + l.Debugf("entering OWNS function with id %s", id.String()) + + // if the id host isn't this instance host, we don't own this IRI + if id.Host != f.config.Host { + l.Debugf("we DO NOT own activity because the host is %s not %s", id.Host, f.config.Host) + return false, nil + } + + // apparently it belongs to this host, so what *is* it? + + // check if it's a status, eg /users/example_username/statuses/SOME_UUID_OF_A_STATUS + if util.IsStatusesPath(id) { + _, uid, err := util.ParseStatusesPath(id) + if err != nil { + return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) + } + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: uid}}, >smodel.Status{}); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // there are no entries for this status + return false, nil + } + // an actual error happened + return false, fmt.Errorf("database error fetching status with id %s: %s", uid, err) + } + l.Debug("we DO own this") + return true, nil + } + + // check if it's a user, eg /users/example_username + if util.IsUserPath(id) { + username, err := util.ParseUserPath(id) + if err != nil { + return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) + } + if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // there are no entries for this username + return false, nil + } + // an actual error happened + return false, fmt.Errorf("database error fetching account with username %s: %s", username, err) + } + l.Debug("we DO own this") + return true, nil + } + + if util.IsFollowersPath(id) { + username, err := util.ParseFollowersPath(id) + if err != nil { + return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) + } + if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // there are no entries for this username + return false, nil + } + // an actual error happened + return false, fmt.Errorf("database error fetching account with username %s: %s", username, err) + } + l.Debug("we DO own this") + return true, nil + } + + if util.IsFollowingPath(id) { + username, err := util.ParseFollowingPath(id) + if err != nil { + return false, fmt.Errorf("error parsing statuses path for url %s: %s", id.String(), err) + } + if err := f.db.GetLocalAccountByUsername(username, >smodel.Account{}); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // there are no entries for this username + return false, nil + } + // an actual error happened + return false, fmt.Errorf("database error fetching account with username %s: %s", username, err) + } + l.Debug("we DO own this") + return true, nil + } + + return false, fmt.Errorf("could not match activityID: %s", id.String()) +} diff --git a/internal/federation/federatingdb/undo.go b/internal/federation/federatingdb/undo.go new file mode 100644 index 000000000..8ca681bb2 --- /dev/null +++ b/internal/federation/federatingdb/undo.go @@ -0,0 +1,89 @@ +package federatingdb + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (f *federatingDB) Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "Undo", + "asType": undo.GetTypeName(), + }, + ) + m, err := streams.Serialize(undo) + if err != nil { + return err + } + b, err := json.Marshal(m) + if err != nil { + return err + } + l.Debugf("received UNDO asType %s", string(b)) + + targetAcctI := ctx.Value(util.APAccount) + if targetAcctI == nil { + l.Error("UNDO: target account wasn't set on context") + return nil + } + targetAcct, ok := targetAcctI.(*gtsmodel.Account) + if !ok { + l.Error("UNDO: target account was set on context but couldn't be parsed") + return nil + } + + undoObject := undo.GetActivityStreamsObject() + if undoObject == nil { + return errors.New("UNDO: no object set on vocab.ActivityStreamsUndo") + } + + for iter := undoObject.Begin(); iter != undoObject.End(); iter = iter.Next() { + switch iter.GetType().GetTypeName() { + case string(gtsmodel.ActivityStreamsFollow): + // UNDO FOLLOW + ASFollow, ok := iter.GetType().(vocab.ActivityStreamsFollow) + if !ok { + return errors.New("UNDO: couldn't parse follow into vocab.ActivityStreamsFollow") + } + // make sure the actor owns the follow + if !sameActor(undo.GetActivityStreamsActor(), ASFollow.GetActivityStreamsActor()) { + return errors.New("UNDO: follow actor and activity actor not the same") + } + // convert the follow to something we can understand + gtsFollow, err := f.typeConverter.ASFollowToFollow(ASFollow) + if err != nil { + return fmt.Errorf("UNDO: error converting asfollow to gtsfollow: %s", err) + } + // make sure the addressee of the original follow is the same as whatever inbox this landed in + if gtsFollow.TargetAccountID != targetAcct.ID { + return errors.New("UNDO: follow object account and inbox account were not the same") + } + // delete any existing FOLLOW + if err := f.db.DeleteWhere([]db.Where{{Key: "uri", Value: gtsFollow.URI}}, >smodel.Follow{}); err != nil { + return fmt.Errorf("UNDO: db error removing follow: %s", err) + } + // delete any existing FOLLOW REQUEST + if err := f.db.DeleteWhere([]db.Where{{Key: "uri", Value: gtsFollow.URI}}, >smodel.FollowRequest{}); err != nil { + return fmt.Errorf("UNDO: db error removing follow request: %s", err) + } + l.Debug("follow undone") + return nil + case string(gtsmodel.ActivityStreamsLike): + // UNDO LIKE + case string(gtsmodel.ActivityStreamsAnnounce): + // UNDO BOOST/REBLOG/ANNOUNCE + } + } + + return nil +} diff --git a/internal/federation/federatingdb/update.go b/internal/federation/federatingdb/update.go new file mode 100644 index 000000000..3ac5f265a --- /dev/null +++ b/internal/federation/federatingdb/update.go @@ -0,0 +1,142 @@ +package federatingdb + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +// Update sets an existing entry to the database based on the value's +// id. +// +// Note that Activity values received from federated peers may also be +// updated in the database this way if the Federating Protocol is +// enabled. The client may freely decide to store only the id instead of +// the entire value. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error { + l := f.log.WithFields( + logrus.Fields{ + "func": "Update", + "asType": asType.GetTypeName(), + }, + ) + m, err := streams.Serialize(asType) + if err != nil { + return err + } + b, err := json.Marshal(m) + if err != nil { + return err + } + + l.Debugf("received UPDATE asType %s", string(b)) + + receivingAcctI := ctx.Value(util.APAccount) + if receivingAcctI == nil { + l.Error("receiving account wasn't set on context") + } + receivingAcct, ok := receivingAcctI.(*gtsmodel.Account) + if !ok { + l.Error("receiving account was set on context but couldn't be parsed") + } + + requestingAcctI := ctx.Value(util.APRequestingAccount) + if receivingAcctI == nil { + l.Error("requesting account wasn't set on context") + } + requestingAcct, ok := requestingAcctI.(*gtsmodel.Account) + if !ok { + l.Error("requesting account was set on context but couldn't be parsed") + } + + fromFederatorChanI := ctx.Value(util.APFromFederatorChanKey) + if fromFederatorChanI == nil { + l.Error("from federator channel wasn't set on context") + } + fromFederatorChan, ok := fromFederatorChanI.(chan gtsmodel.FromFederator) + if !ok { + l.Error("from federator channel was set on context but couldn't be parsed") + } + + typeName := asType.GetTypeName() + if typeName == gtsmodel.ActivityStreamsApplication || + typeName == gtsmodel.ActivityStreamsGroup || + typeName == gtsmodel.ActivityStreamsOrganization || + typeName == gtsmodel.ActivityStreamsPerson || + typeName == gtsmodel.ActivityStreamsService { + // it's an UPDATE to some kind of account + var accountable typeutils.Accountable + + switch asType.GetTypeName() { + case gtsmodel.ActivityStreamsApplication: + l.Debug("got update for APPLICATION") + i, ok := asType.(vocab.ActivityStreamsApplication) + if !ok { + return errors.New("could not convert type to application") + } + accountable = i + case gtsmodel.ActivityStreamsGroup: + l.Debug("got update for GROUP") + i, ok := asType.(vocab.ActivityStreamsGroup) + if !ok { + return errors.New("could not convert type to group") + } + accountable = i + case gtsmodel.ActivityStreamsOrganization: + l.Debug("got update for ORGANIZATION") + i, ok := asType.(vocab.ActivityStreamsOrganization) + if !ok { + return errors.New("could not convert type to organization") + } + accountable = i + case gtsmodel.ActivityStreamsPerson: + l.Debug("got update for PERSON") + i, ok := asType.(vocab.ActivityStreamsPerson) + if !ok { + return errors.New("could not convert type to person") + } + accountable = i + case gtsmodel.ActivityStreamsService: + l.Debug("got update for SERVICE") + i, ok := asType.(vocab.ActivityStreamsService) + if !ok { + return errors.New("could not convert type to service") + } + accountable = i + } + + updatedAcct, err := f.typeConverter.ASRepresentationToAccount(accountable, true) + if err != nil { + return fmt.Errorf("error converting to account: %s", err) + } + + if requestingAcct.URI != updatedAcct.URI { + return fmt.Errorf("update for account %s was requested by account %s, this is not valid", updatedAcct.URI, requestingAcct.URI) + } + + updatedAcct.ID = requestingAcct.ID // set this here so the db will update properly instead of trying to PUT this and getting constraint issues + if err := f.db.UpdateByID(requestingAcct.ID, updatedAcct); err != nil { + return fmt.Errorf("database error inserting updated account: %s", err) + } + + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsProfile, + APActivityType: gtsmodel.ActivityStreamsUpdate, + GTSModel: updatedAcct, + ReceivingAccount: receivingAcct, + } + + } + + return nil +} diff --git a/internal/federation/federatingdb/util.go b/internal/federation/federatingdb/util.go new file mode 100644 index 000000000..be3cff944 --- /dev/null +++ b/internal/federation/federatingdb/util.go @@ -0,0 +1,187 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package federatingdb + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func sameActor(activityActor vocab.ActivityStreamsActorProperty, followActor vocab.ActivityStreamsActorProperty) bool { + if activityActor == nil || followActor == nil { + return false + } + for aIter := activityActor.Begin(); aIter != activityActor.End(); aIter = aIter.Next() { + for fIter := followActor.Begin(); fIter != followActor.End(); fIter = fIter.Next() { + if aIter.GetIRI() == nil { + return false + } + if fIter.GetIRI() == nil { + return false + } + if aIter.GetIRI().String() == fIter.GetIRI().String() { + return true + } + } + } + return false +} + +// NewID creates a new IRI id for the provided activity or object. The +// implementation does not need to set the 'id' property and simply +// needs to determine the value. +// +// The go-fed library will handle setting the 'id' property on the +// activity or object provided with the value returned. +func (f *federatingDB) NewID(c context.Context, t vocab.Type) (id *url.URL, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "NewID", + "asType": t.GetTypeName(), + }, + ) + m, err := streams.Serialize(t) + if err != nil { + return nil, err + } + b, err := json.Marshal(m) + if err != nil { + return nil, err + } + l.Debugf("received NEWID request for asType %s", string(b)) + + switch t.GetTypeName() { + case gtsmodel.ActivityStreamsFollow: + // FOLLOW + // ID might already be set on a follow we've created, so check it here and return it if it is + follow, ok := t.(vocab.ActivityStreamsFollow) + if !ok { + return nil, errors.New("newid: follow couldn't be parsed into vocab.ActivityStreamsFollow") + } + idProp := follow.GetJSONLDId() + if idProp != nil { + if idProp.IsIRI() { + return idProp.GetIRI(), nil + } + } + // it's not set so create one based on the actor set on the follow (ie., the followER not the followEE) + actorProp := follow.GetActivityStreamsActor() + if actorProp != nil { + for iter := actorProp.Begin(); iter != actorProp.End(); iter = iter.Next() { + // take the IRI of the first actor we can find (there should only be one) + if iter.IsIRI() { + actorAccount := >smodel.Account{} + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: iter.GetIRI().String()}}, actorAccount); err == nil { // if there's an error here, just use the fallback behavior -- we don't need to return an error here + return url.Parse(util.GenerateURIForFollow(actorAccount.Username, f.config.Protocol, f.config.Host, uuid.NewString())) + } + } + } + } + case gtsmodel.ActivityStreamsNote: + // NOTE aka STATUS + // ID might already be set on a note we've created, so check it here and return it if it is + note, ok := t.(vocab.ActivityStreamsNote) + if !ok { + return nil, errors.New("newid: note couldn't be parsed into vocab.ActivityStreamsNote") + } + idProp := note.GetJSONLDId() + if idProp != nil { + if idProp.IsIRI() { + return idProp.GetIRI(), nil + } + } + case gtsmodel.ActivityStreamsLike: + // LIKE aka FAVE + // ID might already be set on a fave we've created, so check it here and return it if it is + fave, ok := t.(vocab.ActivityStreamsLike) + if !ok { + return nil, errors.New("newid: fave couldn't be parsed into vocab.ActivityStreamsLike") + } + idProp := fave.GetJSONLDId() + if idProp != nil { + if idProp.IsIRI() { + return idProp.GetIRI(), nil + } + } + } + + // fallback default behavior: just return a random UUID after our protocol and host + return url.Parse(fmt.Sprintf("%s://%s/%s", f.config.Protocol, f.config.Host, uuid.NewString())) +} + +// ActorForOutbox fetches the actor's IRI for the given outbox IRI. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) ActorForOutbox(c context.Context, outboxIRI *url.URL) (actorIRI *url.URL, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "ActorForOutbox", + "inboxIRI": outboxIRI.String(), + }, + ) + l.Debugf("entering ACTORFOROUTBOX function with outboxIRI %s", outboxIRI.String()) + + if !util.IsOutboxPath(outboxIRI) { + return nil, fmt.Errorf("%s is not an outbox URI", outboxIRI.String()) + } + acct := >smodel.Account{} + if err := f.db.GetWhere([]db.Where{{Key: "outbox_uri", Value: outboxIRI.String()}}, acct); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil, fmt.Errorf("no actor found that corresponds to outbox %s", outboxIRI.String()) + } + return nil, fmt.Errorf("db error searching for actor with outbox %s", outboxIRI.String()) + } + return url.Parse(acct.URI) +} + +// ActorForInbox fetches the actor's IRI for the given outbox IRI. +// +// The library makes this call only after acquiring a lock first. +func (f *federatingDB) ActorForInbox(c context.Context, inboxIRI *url.URL) (actorIRI *url.URL, err error) { + l := f.log.WithFields( + logrus.Fields{ + "func": "ActorForInbox", + "inboxIRI": inboxIRI.String(), + }, + ) + l.Debugf("entering ACTORFORINBOX function with inboxIRI %s", inboxIRI.String()) + + if !util.IsInboxPath(inboxIRI) { + return nil, fmt.Errorf("%s is not an inbox URI", inboxIRI.String()) + } + acct := >smodel.Account{} + if err := f.db.GetWhere([]db.Where{{Key: "inbox_uri", Value: inboxIRI.String()}}, acct); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil, fmt.Errorf("no actor found that corresponds to inbox %s", inboxIRI.String()) + } + return nil, fmt.Errorf("db error searching for actor with inbox %s", inboxIRI.String()) + } + return url.Parse(acct.URI) +} diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go index ab4b5ccbc..39ed49cfb 100644 --- a/internal/federation/federatingprotocol.go +++ b/internal/federation/federatingprotocol.go @@ -240,38 +240,20 @@ func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, er // Applications are not expected to handle every single ActivityStreams // type and extension. The unhandled ones are passed to DefaultCallback. func (f *federator) FederatingCallbacks(ctx context.Context) (wrapped pub.FederatingWrappedCallbacks, other []interface{}, err error) { - l := f.log.WithFields(logrus.Fields{ - "func": "FederatingCallbacks", - }) - - receivingAcctI := ctx.Value(util.APAccount) - if receivingAcctI == nil { - l.Error("receiving account wasn't set on context") - return - } - receivingAcct, ok := receivingAcctI.(*gtsmodel.Account) - if !ok { - l.Error("receiving account was set on context but couldn't be parsed") - return - } - - var onFollow pub.OnFollowBehavior = pub.OnFollowAutomaticallyAccept - if receivingAcct.Locked { - onFollow = pub.OnFollowDoNothing - } - wrapped = pub.FederatingWrappedCallbacks{ // OnFollow determines what action to take for this particular callback // if a Follow Activity is handled. - OnFollow: onFollow, + // + // For our implementation, we always want to do nothing because we have internal logic for handling follows. + OnFollow: pub.OnFollowDoNothing, } other = []interface{}{ - // override default undo behavior + // override default undo behavior and trigger our own side effects func(ctx context.Context, undo vocab.ActivityStreamsUndo) error { return f.FederatingDB().Undo(ctx, undo) }, - // override default accept behavior + // override default accept behavior and trigger our own side effects func(ctx context.Context, accept vocab.ActivityStreamsAccept) error { return f.FederatingDB().Accept(ctx, accept) }, diff --git a/internal/federation/federator.go b/internal/federation/federator.go index f09a77279..f2f223a65 100644 --- a/internal/federation/federator.go +++ b/internal/federation/federator.go @@ -26,6 +26,7 @@ "github.com/sirupsen/logrus" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation/federatingdb" "github.com/superseriousbusiness/gotosocial/internal/transport" "github.com/superseriousbusiness/gotosocial/internal/typeutils" ) @@ -35,7 +36,7 @@ type Federator interface { // FederatingActor returns the underlying pub.FederatingActor, which can be used to send activities, and serve actors at inboxes/outboxes. FederatingActor() pub.FederatingActor // FederatingDB returns the underlying FederatingDB interface. - FederatingDB() FederatingDB + FederatingDB() federatingdb.DB // AuthenticateFederatedRequest can be used to check the authenticity of incoming http-signed requests for federating resources. // The given username will be used to create a transport for making outgoing requests. See the implementation for more detailed comments. AuthenticateFederatedRequest(username string, r *http.Request) (*url.URL, error) @@ -54,7 +55,7 @@ type Federator interface { type federator struct { config *config.Config db db.DB - federatingDB FederatingDB + federatingDB federatingdb.DB clock pub.Clock typeConverter typeutils.TypeConverter transportController transport.Controller @@ -63,7 +64,7 @@ type federator struct { } // NewFederator returns a new federator -func NewFederator(db db.DB, federatingDB FederatingDB, transportController transport.Controller, config *config.Config, log *logrus.Logger, typeConverter typeutils.TypeConverter) Federator { +func NewFederator(db db.DB, federatingDB federatingdb.DB, transportController transport.Controller, config *config.Config, log *logrus.Logger, typeConverter typeutils.TypeConverter) Federator { clock := &Clock{} f := &federator{ @@ -84,6 +85,6 @@ func (f *federator) FederatingActor() pub.FederatingActor { return f.actor } -func (f *federator) FederatingDB() FederatingDB { +func (f *federator) FederatingDB() federatingdb.DB { return f.federatingDB } diff --git a/internal/federation/util.go b/internal/federation/util.go index 6ae0152df..837d92759 100644 --- a/internal/federation/util.go +++ b/internal/federation/util.go @@ -280,22 +280,4 @@ func (f *federator) GetTransportForUser(username string) (transport.Transport, e return transport, nil } -func sameActor(activityActor vocab.ActivityStreamsActorProperty, followActor vocab.ActivityStreamsActorProperty) bool { - if activityActor == nil || followActor == nil { - return false - } - for aIter := activityActor.Begin(); aIter != activityActor.End(); aIter = aIter.Next() { - for fIter := followActor.Begin(); fIter != followActor.End(); fIter = fIter.Next() { - if aIter.GetIRI() == nil { - return false - } - if fIter.GetIRI() == nil { - return false - } - if aIter.GetIRI().String() == fIter.GetIRI().String() { - return true - } - } - } - return false -} + diff --git a/internal/gotosocial/actions.go b/internal/gotosocial/actions.go index 8e6c50a61..824754049 100644 --- a/internal/gotosocial/actions.go +++ b/internal/gotosocial/actions.go @@ -37,6 +37,7 @@ "github.com/superseriousbusiness/gotosocial/internal/api/client/followrequest" "github.com/superseriousbusiness/gotosocial/internal/api/client/instance" mediaModule "github.com/superseriousbusiness/gotosocial/internal/api/client/media" + "github.com/superseriousbusiness/gotosocial/internal/api/client/notification" "github.com/superseriousbusiness/gotosocial/internal/api/client/status" "github.com/superseriousbusiness/gotosocial/internal/api/client/timeline" "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" @@ -45,6 +46,7 @@ "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db/pg" "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/federation/federatingdb" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/media" "github.com/superseriousbusiness/gotosocial/internal/message" @@ -73,6 +75,7 @@ >smodel.User{}, >smodel.Emoji{}, >smodel.Instance{}, + >smodel.Notification{}, &oauth.Token{}, &oauth.Client{}, } @@ -84,7 +87,7 @@ return fmt.Errorf("error creating dbservice: %s", err) } - federatingDB := federation.NewFederatingDB(dbService, c, log) + federatingDB := federatingdb.New(dbService, c, log) router, err := router.New(c, log) if err != nil { @@ -118,6 +121,7 @@ webfingerModule := webfinger.New(c, processor, log) usersModule := user.New(c, processor, log) timelineModule := timeline.New(c, processor, log) + notificationModule := notification.New(c, processor, log) mm := mediaModule.New(c, processor, log) fileServerModule := fileserver.New(c, processor, log) adminModule := admin.New(c, processor, log) @@ -141,6 +145,7 @@ webfingerModule, usersModule, timelineModule, + notificationModule, } for _, m := range apis { diff --git a/internal/gtsmodel/notification.go b/internal/gtsmodel/notification.go new file mode 100644 index 000000000..35e0ca173 --- /dev/null +++ b/internal/gtsmodel/notification.go @@ -0,0 +1,70 @@ +/* + GoToSocial + Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +package gtsmodel + +import "time" + +// Notification models an alert/notification sent to an account about something like a reblog, like, new follow request, etc. +type Notification struct { + // ID of this notification in the database + ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` + // Type of this notification + NotificationType NotificationType `pg:",notnull"` + // Creation time of this notification + CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` + // Which account does this notification target (ie., who will receive the notification?) + TargetAccountID string `pg:",notnull"` + // Which account performed the action that created this notification? + OriginAccountID string `pg:",notnull"` + // If the notification pertains to a status, what is the database ID of that status? + StatusID string + // Has this notification been read already? + Read bool + + /* + NON-DATABASE fields + */ + + // gts model of the target account, won't be put in the database, it's just for convenience when passing the notification around. + GTSTargetAccount *Account `pg:"-"` + // gts model of the origin account, won't be put in the database, it's just for convenience when passing the notification around. + GTSOriginAccount *Account `pg:"-"` + // gts model of the relevant status, won't be put in the database, it's just for convenience when passing the notification around. + GTSStatus *Status `pg:"-"` +} + +// NotificationType describes the reason/type of this notification. +type NotificationType string + +const ( + // NotificationFollow -- someone followed you + NotificationFollow NotificationType = "follow" + // NotificationFollowRequest -- someone requested to follow you + NotificationFollowRequest NotificationType = "follow_request" + // NotificationMention -- someone mentioned you in their status + NotificationMention NotificationType = "mention" + // NotificationReblog -- someone boosted one of your statuses + NotificationReblog NotificationType = "reblog" + // NotifiationFave -- someone faved/liked one of your statuses + NotificationFave NotificationType = "favourite" + // NotificationPoll -- a poll you voted in or created has ended + NotificationPoll NotificationType = "poll" + // NotificationStatus -- someone you enabled notifications for has posted a status. + NotificationStatus NotificationType = "status" +) diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go index b5ac8def1..16c00ca73 100644 --- a/internal/gtsmodel/status.go +++ b/internal/gtsmodel/status.go @@ -80,7 +80,7 @@ type Status struct { */ // Account that created this status - GTSAccount *Account `pg:"-"` + GTSAuthorAccount *Account `pg:"-"` // Mentions created in this status GTSMentions []*Mention `pg:"-"` // Hashtags used in this status diff --git a/internal/message/fromclientapiprocess.go b/internal/message/fromclientapiprocess.go index b0112152b..12e4bd3c0 100644 --- a/internal/message/fromclientapiprocess.go +++ b/internal/message/fromclientapiprocess.go @@ -49,17 +49,17 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error } return nil case gtsmodel.ActivityStreamsFollow: - // CREATE FOLLOW (request) - follow, ok := clientMsg.GTSModel.(*gtsmodel.Follow) + // CREATE FOLLOW REQUEST + followRequest, ok := clientMsg.GTSModel.(*gtsmodel.FollowRequest) if !ok { - return errors.New("follow was not parseable as *gtsmodel.Follow") + return errors.New("followrequest was not parseable as *gtsmodel.FollowRequest") } - if err := p.notifyFollow(follow); err != nil { + if err := p.notifyFollowRequest(followRequest, clientMsg.TargetAccount); err != nil { return err } - return p.federateFollow(follow, clientMsg.OriginAccount, clientMsg.TargetAccount) + return p.federateFollow(followRequest, clientMsg.OriginAccount, clientMsg.TargetAccount) case gtsmodel.ActivityStreamsLike: // CREATE LIKE/FAVE fave, ok := clientMsg.GTSModel.(*gtsmodel.StatusFave) @@ -67,7 +67,7 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error return errors.New("fave was not parseable as *gtsmodel.StatusFave") } - if err := p.notifyFave(fave); err != nil { + if err := p.notifyFave(fave, clientMsg.TargetAccount); err != nil { return err } @@ -84,6 +84,11 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error if !ok { return errors.New("accept was not parseable as *gtsmodel.Follow") } + + if err := p.notifyFollow(follow, clientMsg.TargetAccount); err != nil { + return err + } + return p.federateAcceptFollowRequest(follow, clientMsg.OriginAccount, clientMsg.TargetAccount) } case gtsmodel.ActivityStreamsUndo: @@ -107,21 +112,23 @@ func (p *processor) federateStatus(status *gtsmodel.Status) error { return fmt.Errorf("federateStatus: error converting status to as format: %s", err) } - outboxIRI, err := url.Parse(status.GTSAccount.OutboxURI) + outboxIRI, err := url.Parse(status.GTSAuthorAccount.OutboxURI) if err != nil { - return fmt.Errorf("federateStatus: error parsing outboxURI %s: %s", status.GTSAccount.OutboxURI, err) + return fmt.Errorf("federateStatus: error parsing outboxURI %s: %s", status.GTSAuthorAccount.OutboxURI, err) } _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, asStatus) return err } -func (p *processor) federateFollow(follow *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { +func (p *processor) federateFollow(followRequest *gtsmodel.FollowRequest, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) error { // if both accounts are local there's nothing to do here if originAccount.Domain == "" && targetAccount.Domain == "" { return nil } + follow := p.tc.FollowRequestToFollow(followRequest) + asFollow, err := p.tc.FollowToAS(follow, originAccount, targetAccount) if err != nil { return fmt.Errorf("federateFollow: error converting follow to as format: %s", err) diff --git a/internal/message/fromcommonprocess.go b/internal/message/fromcommonprocess.go index 2403a8b72..73d58f1d1 100644 --- a/internal/message/fromcommonprocess.go +++ b/internal/message/fromcommonprocess.go @@ -18,16 +18,143 @@ package message -import "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +import ( + "fmt" + + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) func (p *processor) notifyStatus(status *gtsmodel.Status) error { + // if there are no mentions in this status then just bail + if len(status.Mentions) == 0 { + return nil + } + + if status.GTSMentions == nil { + // there are mentions but they're not fully populated on the status yet so do this + menchies := []*gtsmodel.Mention{} + for _, m := range status.Mentions { + gtsm := >smodel.Mention{} + if err := p.db.GetByID(m, gtsm); err != nil { + return fmt.Errorf("notifyStatus: error getting mention with id %s from the db: %s", m, err) + } + menchies = append(menchies, gtsm) + } + status.GTSMentions = menchies + } + + // now we have mentions as full gtsmodel.Mention structs on the status we can continue + for _, m := range status.GTSMentions { + // make sure this is a local account, otherwise we don't need to create a notification for it + if m.GTSAccount == nil { + a := >smodel.Account{} + if err := p.db.GetByID(m.TargetAccountID, a); err != nil { + // we don't have the account or there's been an error + return fmt.Errorf("notifyStatus: error getting account with id %s from the db: %s", m.TargetAccountID, err) + } + m.GTSAccount = a + } + if m.GTSAccount.Domain != "" { + // not a local account so skip it + continue + } + + // make sure a notif doesn't already exist for this mention + err := p.db.GetWhere([]db.Where{ + {Key: "notification_type", Value: gtsmodel.NotificationMention}, + {Key: "target_account_id", Value: m.TargetAccountID}, + {Key: "origin_account_id", Value: status.AccountID}, + {Key: "status_id", Value: status.ID}, + }, >smodel.Notification{}) + if err == nil { + // notification exists already so just continue + continue + } + if _, ok := err.(db.ErrNoEntries); !ok { + // there's a real error in the db + return fmt.Errorf("notifyStatus: error checking existence of notification for mention with id %s : %s", m.ID, err) + } + + // if we've reached this point we know the mention is for a local account, and the notification doesn't exist, so create it + notif := >smodel.Notification{ + NotificationType: gtsmodel.NotificationMention, + TargetAccountID: m.TargetAccountID, + OriginAccountID: status.AccountID, + StatusID: status.ID, + } + + if err := p.db.Put(notif); err != nil { + return fmt.Errorf("notifyStatus: error putting notification in database: %s", err) + } + } + return nil } -func (p *processor) notifyFollow(follow *gtsmodel.Follow) error { +func (p *processor) notifyFollowRequest(followRequest *gtsmodel.FollowRequest, receivingAccount *gtsmodel.Account) error { + // return if this isn't a local account + if receivingAccount.Domain != "" { + return nil + } + + notif := >smodel.Notification{ + NotificationType: gtsmodel.NotificationFollowRequest, + TargetAccountID: followRequest.TargetAccountID, + OriginAccountID: followRequest.AccountID, + } + + if err := p.db.Put(notif); err != nil { + return fmt.Errorf("notifyFollowRequest: error putting notification in database: %s", err) + } + return nil } -func (p *processor) notifyFave(fave *gtsmodel.StatusFave) error { - return nil +func (p *processor) notifyFollow(follow *gtsmodel.Follow, receivingAccount *gtsmodel.Account) error { + // return if this isn't a local account + if receivingAccount.Domain != "" { + return nil + } + + // first remove the follow request notification + if err := p.db.DeleteWhere([]db.Where{ + {Key: "notification_type", Value: gtsmodel.NotificationFollowRequest}, + {Key: "target_account_id", Value: follow.TargetAccountID}, + {Key: "origin_account_id", Value: follow.AccountID}, + }, >smodel.Notification{}); err != nil { + return fmt.Errorf("notifyFollow: error removing old follow request notification from database: %s", err) + } + + // now create the new follow notification + notif := >smodel.Notification{ + NotificationType: gtsmodel.NotificationFollow, + TargetAccountID: follow.TargetAccountID, + OriginAccountID: follow.AccountID, + } + if err := p.db.Put(notif); err != nil { + return fmt.Errorf("notifyFollow: error putting notification in database: %s", err) + } + + return nil +} + +func (p *processor) notifyFave(fave *gtsmodel.StatusFave, receivingAccount *gtsmodel.Account) error { + // return if this isn't a local account + if receivingAccount.Domain != "" { + return nil + } + + notif := >smodel.Notification{ + NotificationType: gtsmodel.NotificationFave, + TargetAccountID: fave.TargetAccountID, + OriginAccountID: fave.AccountID, + StatusID: fave.StatusID, + } + + if err := p.db.Put(notif); err != nil { + return fmt.Errorf("notifyFave: error putting notification in database: %s", err) + } + + return nil } diff --git a/internal/message/fromfederatorprocess.go b/internal/message/fromfederatorprocess.go index d3ebce400..10dbfcf6e 100644 --- a/internal/message/fromfederatorprocess.go +++ b/internal/message/fromfederatorprocess.go @@ -74,6 +74,26 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er if err := p.db.UpdateByID(incomingAccount.ID, incomingAccount); err != nil { return fmt.Errorf("error updating dereferenced account in the db: %s", err) } + case gtsmodel.ActivityStreamsLike: + // CREATE A FAVE + incomingFave, ok := federatorMsg.GTSModel.(*gtsmodel.StatusFave) + if !ok { + return errors.New("like was not parseable as *gtsmodel.StatusFave") + } + + if err := p.notifyFave(incomingFave, federatorMsg.ReceivingAccount); err != nil { + return err + } + case gtsmodel.ActivityStreamsFollow: + // CREATE A FOLLOW REQUEST + incomingFollowRequest, ok := federatorMsg.GTSModel.(*gtsmodel.FollowRequest) + if !ok { + return errors.New("like was not parseable as *gtsmodel.FollowRequest") + } + + if err := p.notifyFollowRequest(incomingFollowRequest, federatorMsg.ReceivingAccount); err != nil { + return err + } } case gtsmodel.ActivityStreamsUpdate: // UPDATE @@ -106,6 +126,20 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er // DELETE A PROFILE/ACCOUNT // TODO: handle side effects of account deletion here: delete all objects, statuses, media etc associated with account } + case gtsmodel.ActivityStreamsAccept: + // ACCEPT + switch federatorMsg.APObjectType { + case gtsmodel.ActivityStreamsFollow: + // ACCEPT A FOLLOW + follow, ok := federatorMsg.GTSModel.(*gtsmodel.Follow) + if !ok { + return errors.New("follow was not parseable as *gtsmodel.Follow") + } + + if err := p.notifyFollow(follow, federatorMsg.ReceivingAccount); err != nil { + return err + } + } } return nil @@ -215,8 +249,8 @@ func (p *processor) dereferenceStatusFields(status *gtsmodel.Status) error { } m.StatusID = status.ID - m.OriginAccountID = status.GTSAccount.ID - m.OriginAccountURI = status.GTSAccount.URI + m.OriginAccountID = status.GTSAuthorAccount.ID + m.OriginAccountURI = status.GTSAuthorAccount.URI targetAccount := >smodel.Account{} if err := p.db.GetWhere([]db.Where{{Key: "uri", Value: uri.String()}}, targetAccount); err != nil { diff --git a/internal/message/notificationsprocess.go b/internal/message/notificationsprocess.go new file mode 100644 index 000000000..64726b75f --- /dev/null +++ b/internal/message/notificationsprocess.go @@ -0,0 +1,24 @@ +package message + +import ( + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (p *processor) NotificationsGet(authed *oauth.Auth, limit int, maxID string) ([]*apimodel.Notification, ErrorWithCode) { + notifs, err := p.db.GetNotificationsForAccount(authed.Account.ID, limit, maxID) + if err != nil { + return nil, NewErrorInternalError(err) + } + + mastoNotifs := []*apimodel.Notification{} + for _, n := range notifs { + mastoNotif, err := p.tc.NotificationToMasto(n) + if err != nil { + return nil, NewErrorInternalError(err) + } + mastoNotifs = append(mastoNotifs, mastoNotif) + } + + return mastoNotifs, nil +} diff --git a/internal/message/processor.go b/internal/message/processor.go index bcd64d47a..49a4f6f05 100644 --- a/internal/message/processor.go +++ b/internal/message/processor.go @@ -106,6 +106,9 @@ type Processor interface { // MediaUpdate handles the PUT of a media attachment with the given ID and form MediaUpdate(authed *oauth.Auth, attachmentID string, form *apimodel.AttachmentUpdateRequest) (*apimodel.Attachment, ErrorWithCode) + // NotificationsGet + NotificationsGet(authed *oauth.Auth, limit int, maxID string) ([]*apimodel.Notification, ErrorWithCode) + // StatusCreate processes the given form to create a new status, returning the api model representation of that status if it's OK. StatusCreate(authed *oauth.Auth, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, error) // StatusDelete processes the delete of a given status, returning the deleted status if the delete goes through. diff --git a/internal/message/statusprocess.go b/internal/message/statusprocess.go index 6786b2dab..f64c35948 100644 --- a/internal/message/statusprocess.go +++ b/internal/message/statusprocess.go @@ -278,54 +278,14 @@ func (p *processor) StatusBoost(authed *oauth.Auth, targetStatusID string) (*api } // it's visible! it's boostable! so let's boost the FUCK out of it - // first we create a new status and add some basic info to it -- this will be the wrapper for the boosted status - - // the wrapper won't use the same ID as the boosted status so we generate some new UUIDs - uris := util.GenerateURIsForAccount(authed.Account.Username, p.config.Protocol, p.config.Host) - boostWrapperStatusID := uuid.NewString() - boostWrapperStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, boostWrapperStatusID) - boostWrapperStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, boostWrapperStatusID) - - boostWrapperStatus := >smodel.Status{ - ID: boostWrapperStatusID, - URI: boostWrapperStatusURI, - URL: boostWrapperStatusURL, - - // the boosted status is not created now, but the boost certainly is - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Local: true, // always local since this is being done through the client API - AccountID: authed.Account.ID, - CreatedWithApplicationID: authed.Application.ID, - - // replies can be boosted, but boosts are never replies - InReplyToID: "", - InReplyToAccountID: "", - - // these will all be wrapped in the boosted status so set them empty here - Attachments: []string{}, - Tags: []string{}, - Mentions: []string{}, - Emojis: []string{}, - - // the below fields will be taken from the target status - Content: util.HTMLFormat(targetStatus.Content), - ContentWarning: targetStatus.ContentWarning, - ActivityStreamsType: targetStatus.ActivityStreamsType, - Sensitive: targetStatus.Sensitive, - Language: targetStatus.Language, - Text: targetStatus.Text, - BoostOfID: targetStatus.ID, - Visibility: targetStatus.Visibility, - VisibilityAdvanced: targetStatus.VisibilityAdvanced, - - // attach these here for convenience -- the boosted status/account won't go in the DB - // but they're needed in the processor and for the frontend. Since we have them, we can - // attach them so we don't need to fetch them again later (save some DB calls) - GTSBoostedStatus: targetStatus, - GTSBoostedAccount: targetAccount, + boostWrapperStatus, err := p.tc.StatusToBoost(targetStatus, authed.Account) + if err != nil { + return nil, NewErrorInternalError(err) } + boostWrapperStatus.CreatedWithApplicationID = authed.Application.ID + boostWrapperStatus.GTSBoostedAccount = targetAccount + // put the boost in the database if err := p.db.Put(boostWrapperStatus); err != nil { return nil, NewErrorInternalError(err) diff --git a/internal/typeutils/asinterfaces.go b/internal/typeutils/asinterfaces.go index c31a37a25..eea7fd7d9 100644 --- a/internal/typeutils/asinterfaces.go +++ b/internal/typeutils/asinterfaces.go @@ -102,6 +102,15 @@ type Followable interface { withObject } +// Likeable represents the minimum interface for an activitystreams 'like' activity. +type Likeable interface { + withJSONLDId + withTypeName + + withActor + withObject +} + type withJSONLDId interface { GetJSONLDId() vocab.JSONLDIdProperty } diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index dcc2674cd..0fee13b13 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -226,7 +226,7 @@ func (c *converter) ASStatusToStatus(statusable Statusable) (*gtsmodel.Status, e return nil, fmt.Errorf("couldn't get status owner from db: %s", err) } status.AccountID = statusOwner.ID - status.GTSAccount = statusOwner + status.GTSAuthorAccount = statusOwner // check if there's a post that this is a reply to inReplyToURI, err := extractInReplyToURI(statusable) @@ -380,6 +380,48 @@ func (c *converter) ASFollowToFollow(followable Followable) (*gtsmodel.Follow, e return follow, nil } +func (c *converter) ASLikeToFave(likeable Likeable) (*gtsmodel.StatusFave, error) { + idProp := likeable.GetJSONLDId() + if idProp == nil || !idProp.IsIRI() { + return nil, errors.New("no id property set on like, or was not an iri") + } + uri := idProp.GetIRI().String() + + origin, err := extractActor(likeable) + if err != nil { + return nil, errors.New("error extracting actor property from like") + } + originAccount := >smodel.Account{} + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: origin.String()}}, originAccount); err != nil { + return nil, fmt.Errorf("error extracting account with uri %s from the database: %s", origin.String(), err) + } + + target, err := extractObject(likeable) + if err != nil { + return nil, errors.New("error extracting object property from like") + } + + targetStatus := >smodel.Status{} + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: target.String()}}, targetStatus); err != nil { + return nil, fmt.Errorf("error extracting status with uri %s from the database: %s", target.String(), err) + } + + targetAccount := >smodel.Account{} + if err := c.db.GetByID(targetStatus.AccountID, targetAccount); err != nil { + return nil, fmt.Errorf("error extracting account with id %s from the database: %s", targetStatus.AccountID, err) + } + + return >smodel.StatusFave{ + TargetAccountID: targetAccount.ID, + StatusID: targetStatus.ID, + AccountID: originAccount.ID, + URI: uri, + GTSStatus: targetStatus, + GTSTargetAccount: targetAccount, + GTSFavingAccount: originAccount, + }, nil +} + func isPublic(tos []*url.URL) bool { for _, entry := range tos { if strings.EqualFold(entry.String(), "https://www.w3.org/ns/activitystreams#Public") { diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go index 3ced20926..cf94faf2e 100644 --- a/internal/typeutils/converter.go +++ b/internal/typeutils/converter.go @@ -44,45 +44,36 @@ type TypeConverter interface { // if something goes wrong. The returned account should be ready to serialize on an API level, and may have sensitive fields, // so serve it only to an authorized user who should have permission to see it. AccountToMastoSensitive(account *gtsmodel.Account) (*model.Account, error) - // AccountToMastoPublic takes a db model account as a param, and returns a populated mastotype account, or an error // if something goes wrong. The returned account should be ready to serialize on an API level, and may NOT have sensitive fields. // In other words, this is the public record that the server has of an account. AccountToMastoPublic(account *gtsmodel.Account) (*model.Account, error) - // AppToMastoSensitive takes a db model application as a param, and returns a populated mastotype application, or an error // if something goes wrong. The returned application should be ready to serialize on an API level, and may have sensitive fields // (such as client id and client secret), so serve it only to an authorized user who should have permission to see it. AppToMastoSensitive(application *gtsmodel.Application) (*model.Application, error) - // AppToMastoPublic takes a db model application as a param, and returns a populated mastotype application, or an error // if something goes wrong. The returned application should be ready to serialize on an API level, and has sensitive // fields sanitized so that it can be served to non-authorized accounts without revealing any private information. AppToMastoPublic(application *gtsmodel.Application) (*model.Application, error) - // AttachmentToMasto converts a gts model media attacahment into its mastodon representation for serialization on the API. AttachmentToMasto(attachment *gtsmodel.MediaAttachment) (model.Attachment, error) - // MentionToMasto converts a gts model mention into its mastodon (frontend) representation for serialization on the API. MentionToMasto(m *gtsmodel.Mention) (model.Mention, error) - // EmojiToMasto converts a gts model emoji into its mastodon (frontend) representation for serialization on the API. EmojiToMasto(e *gtsmodel.Emoji) (model.Emoji, error) - // TagToMasto converts a gts model tag into its mastodon (frontend) representation for serialization on the API. TagToMasto(t *gtsmodel.Tag) (model.Tag, error) - // StatusToMasto converts a gts model status into its mastodon (frontend) representation for serialization on the API. - StatusToMasto(s *gtsmodel.Status, targetAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*model.Status, error) - + StatusToMasto(s *gtsmodel.Status, statusAuthor *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, reblogOfStatus *gtsmodel.Status) (*model.Status, error) // VisToMasto converts a gts visibility into its mastodon equivalent VisToMasto(m gtsmodel.Visibility) model.Visibility - // InstanceToMasto converts a gts instance into its mastodon equivalent for serving at /api/v1/instance InstanceToMasto(i *gtsmodel.Instance) (*model.Instance, error) - // RelationshipToMasto converts a gts relationship into its mastodon equivalent for serving in various places RelationshipToMasto(r *gtsmodel.Relationship) (*model.Relationship, error) + // NotificationToMasto converts a gts notification into a mastodon notification + NotificationToMasto(n *gtsmodel.Notification) (*model.Notification, error) /* FRONTEND (mastodon) MODEL TO INTERNAL (gts) MODEL @@ -107,6 +98,8 @@ type TypeConverter interface { ASFollowToFollowRequest(followable Followable) (*gtsmodel.FollowRequest, error) // ASFollowToFollowRequest converts a remote activitystreams `follow` representation into gts model follow. ASFollowToFollow(followable Followable) (*gtsmodel.Follow, error) + // ASLikeToFave converts a remote activitystreams 'like' representation into a gts model status fave. + ASLikeToFave(likeable Likeable) (*gtsmodel.StatusFave, error) /* INTERNAL (gts) MODEL TO ACTIVITYSTREAMS MODEL @@ -114,21 +107,25 @@ type TypeConverter interface { // AccountToAS converts a gts model account into an activity streams person, suitable for federation AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) - // StatusToAS converts a gts model status into an activity streams note, suitable for federation StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) - // FollowToASFollow converts a gts model Follow into an activity streams Follow, suitable for federation FollowToAS(f *gtsmodel.Follow, originAccount *gtsmodel.Account, targetAccount *gtsmodel.Account) (vocab.ActivityStreamsFollow, error) - // MentionToAS converts a gts model mention into an activity streams Mention, suitable for federation MentionToAS(m *gtsmodel.Mention) (vocab.ActivityStreamsMention, error) - // AttachmentToAS converts a gts model media attachment into an activity streams Attachment, suitable for federation AttachmentToAS(a *gtsmodel.MediaAttachment) (vocab.ActivityStreamsDocument, error) - // FaveToAS converts a gts model status fave into an activityStreams LIKE, suitable for federation. FaveToAS(f *gtsmodel.StatusFave) (vocab.ActivityStreamsLike, error) + + /* + INTERNAL (gts) MODEL TO INTERNAL MODEL + */ + + // FollowRequestToFollow just converts a follow request into a follow, that's it! No bells and whistles. + FollowRequestToFollow(f *gtsmodel.FollowRequest) *gtsmodel.Follow + // StatusToBoost wraps the given status into a boosting status. + StatusToBoost(s *gtsmodel.Status, boostingAccount *gtsmodel.Account) (*gtsmodel.Status, error) } type converter struct { diff --git a/internal/typeutils/internal.go b/internal/typeutils/internal.go new file mode 100644 index 000000000..3110b382c --- /dev/null +++ b/internal/typeutils/internal.go @@ -0,0 +1,76 @@ +package typeutils + +import ( + "fmt" + "time" + + "github.com/google/uuid" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (c *converter) FollowRequestToFollow(f *gtsmodel.FollowRequest) *gtsmodel.Follow { + return >smodel.Follow{ + ID: f.ID, + CreatedAt: f.CreatedAt, + UpdatedAt: f.UpdatedAt, + AccountID: f.AccountID, + TargetAccountID: f.TargetAccountID, + ShowReblogs: f.ShowReblogs, + URI: f.URI, + Notify: f.Notify, + } +} + +func (c *converter) StatusToBoost(s *gtsmodel.Status, boostingAccount *gtsmodel.Account) (*gtsmodel.Status, error) { + // the wrapper won't use the same ID as the boosted status so we generate some new UUIDs + uris := util.GenerateURIsForAccount(boostingAccount.Username, c.config.Protocol, c.config.Host) + boostWrapperStatusID := uuid.NewString() + boostWrapperStatusURI := fmt.Sprintf("%s/%s", uris.StatusesURI, boostWrapperStatusID) + boostWrapperStatusURL := fmt.Sprintf("%s/%s", uris.StatusesURL, boostWrapperStatusID) + + local := true + if boostingAccount.Domain != "" { + local = false + } + + boostWrapperStatus := >smodel.Status{ + ID: boostWrapperStatusID, + URI: boostWrapperStatusURI, + URL: boostWrapperStatusURL, + + // the boosted status is not created now, but the boost certainly is + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Local: local, + AccountID: boostingAccount.ID, + + // replies can be boosted, but boosts are never replies + InReplyToID: "", + InReplyToAccountID: "", + + // these will all be wrapped in the boosted status so set them empty here + Attachments: []string{}, + Tags: []string{}, + Mentions: []string{}, + Emojis: []string{}, + + // the below fields will be taken from the target status + Content: util.HTMLFormat(s.Content), + ContentWarning: s.ContentWarning, + ActivityStreamsType: s.ActivityStreamsType, + Sensitive: s.Sensitive, + Language: s.Language, + Text: s.Text, + BoostOfID: s.ID, + Visibility: s.Visibility, + VisibilityAdvanced: s.VisibilityAdvanced, + + // attach these here for convenience -- the boosted status/account won't go in the DB + // but they're needed in the processor and for the frontend. Since we have them, we can + // attach them so we don't need to fetch them again later (save some DB calls) + GTSBoostedStatus: s, + } + + return boostWrapperStatus, nil +} diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index 821720e0d..805f69afb 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -262,12 +262,12 @@ func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, e // check if author account is already attached to status and attach it if not // if we can't retrieve this, bail here already because we can't attribute the status to anyone - if s.GTSAccount == nil { + if s.GTSAuthorAccount == nil { a := >smodel.Account{} if err := c.db.GetByID(s.AccountID, a); err != nil { return nil, fmt.Errorf("StatusToAS: error retrieving author account from db: %s", err) } - s.GTSAccount = a + s.GTSAuthorAccount = a } // create the Note! @@ -328,9 +328,9 @@ func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, e } // attributedTo - authorAccountURI, err := url.Parse(s.GTSAccount.URI) + authorAccountURI, err := url.Parse(s.GTSAuthorAccount.URI) if err != nil { - return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.GTSAccount.URI, err) + return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.GTSAuthorAccount.URI, err) } attributedToProp := streams.NewActivityStreamsAttributedToProperty() attributedToProp.AppendIRI(authorAccountURI) @@ -357,9 +357,9 @@ func (c *converter) StatusToAS(s *gtsmodel.Status) (vocab.ActivityStreamsNote, e status.SetActivityStreamsTag(tagProp) // parse out some URIs we need here - authorFollowersURI, err := url.Parse(s.GTSAccount.FollowersURI) + authorFollowersURI, err := url.Parse(s.GTSAuthorAccount.FollowersURI) if err != nil { - return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.GTSAccount.FollowersURI, err) + return nil, fmt.Errorf("StatusToAS: error parsing url %s: %s", s.GTSAuthorAccount.FollowersURI, err) } publicURI, err := url.Parse(asPublicURI) diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go index 7fbe9eb3f..de3b94e01 100644 --- a/internal/typeutils/internaltofrontend.go +++ b/internal/typeutils/internaltofrontend.go @@ -138,6 +138,9 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, e fields = append(fields, mField) } + emojis := []model.Emoji{} + // TODO: account emojis + var acct string if a.Domain != "" { // this is a remote user @@ -165,7 +168,7 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, e FollowingCount: followingCount, StatusesCount: statusesCount, LastStatusAt: lastStatusAt, - Emojis: nil, // TODO: implement this + Emojis: emojis, // TODO: implement this Fields: fields, }, nil } @@ -267,7 +270,7 @@ func (c *converter) TagToMasto(t *gtsmodel.Tag) (model.Tag, error) { func (c *converter) StatusToMasto( s *gtsmodel.Status, - targetAccount *gtsmodel.Account, + statusAuthor *gtsmodel.Account, requestingAccount *gtsmodel.Account, boostOfAccount *gtsmodel.Account, replyToAccount *gtsmodel.Account, @@ -379,7 +382,7 @@ func (c *converter) StatusToMasto( } } - mastoTargetAccount, err := c.AccountToMastoPublic(targetAccount) + mastoAuthorAccount, err := c.AccountToMastoPublic(statusAuthor) if err != nil { return nil, fmt.Errorf("error parsing account of status author: %s", err) } @@ -517,7 +520,7 @@ func (c *converter) StatusToMasto( Content: s.Content, Reblog: mastoRebloggedStatus, Application: mastoApplication, - Account: mastoTargetAccount, + Account: mastoAuthorAccount, MediaAttachments: mastoAttachments, Mentions: mastoMentions, Tags: mastoTags, @@ -594,3 +597,68 @@ func (c *converter) RelationshipToMasto(r *gtsmodel.Relationship) (*model.Relati Note: r.Note, }, nil } + +func (c *converter) NotificationToMasto(n *gtsmodel.Notification) (*model.Notification, error) { + + if n.GTSTargetAccount == nil { + tAccount := >smodel.Account{} + if err := c.db.GetByID(n.TargetAccountID, tAccount); err != nil { + return nil, fmt.Errorf("NotificationToMasto: error getting target account with id %s from the db: %s", n.TargetAccountID, err) + } + n.GTSTargetAccount = tAccount + } + + if n.GTSOriginAccount == nil { + ogAccount := >smodel.Account{} + if err := c.db.GetByID(n.OriginAccountID, ogAccount); err != nil { + return nil, fmt.Errorf("NotificationToMasto: error getting origin account with id %s from the db: %s", n.OriginAccountID, err) + } + n.GTSOriginAccount = ogAccount + } + mastoAccount, err := c.AccountToMastoPublic(n.GTSOriginAccount) + if err != nil { + return nil, fmt.Errorf("NotificationToMasto: error converting account to masto: %s", err) + } + + var mastoStatus *model.Status + if n.StatusID != "" { + if n.GTSStatus == nil { + status := >smodel.Status{} + if err := c.db.GetByID(n.StatusID, status); err != nil { + return nil, fmt.Errorf("NotificationToMasto: error getting status with id %s from the db: %s", n.StatusID, err) + } + n.GTSStatus = status + } + + var replyToAccount *gtsmodel.Account + if n.GTSStatus.InReplyToAccountID != "" { + r := >smodel.Account{} + if err := c.db.GetByID(n.GTSStatus.InReplyToAccountID, r); err != nil { + return nil, fmt.Errorf("NotificationToMasto: error getting replied to account with id %s from the db: %s", n.GTSStatus.InReplyToAccountID, err) + } + replyToAccount = r + } + + if n.GTSStatus.GTSAuthorAccount == nil { + if n.GTSStatus.AccountID == n.GTSTargetAccount.ID { + n.GTSStatus.GTSAuthorAccount = n.GTSTargetAccount + } else if n.GTSStatus.AccountID == n.GTSOriginAccount.ID { + n.GTSStatus.GTSAuthorAccount = n.GTSOriginAccount + } + } + + var err error + mastoStatus, err = c.StatusToMasto(n.GTSStatus, n.GTSStatus.GTSAuthorAccount, n.GTSTargetAccount, nil, replyToAccount, nil) + if err != nil { + return nil, fmt.Errorf("NotificationToMasto: error converting status to masto: %s", err) + } + } + + return &model.Notification{ + ID: n.ID, + Type: string(n.NotificationType), + CreatedAt: n.CreatedAt.Format(time.RFC3339), + Account: mastoAccount, + Status: mastoStatus, + }, nil +} diff --git a/testrig/federatingdb.go b/testrig/federatingdb.go index 5cce24752..6e099fac9 100644 --- a/testrig/federatingdb.go +++ b/testrig/federatingdb.go @@ -2,10 +2,10 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/db" - "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/federation/federatingdb" ) // NewTestFederatingDB returns a federating DB with the underlying db -func NewTestFederatingDB(db db.DB) federation.FederatingDB { - return federation.NewFederatingDB(db, NewTestConfig(), NewTestLog()) +func NewTestFederatingDB(db db.DB) federatingdb.DB { + return federatingdb.New(db, NewTestConfig(), NewTestLog()) }