gtsocial-umbx

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 846057f0d696fded87d105dec1245e9ba32763ce
parent c7da64922f8b41daaee1cb8fc2961f7fa1336737
Author: Tobi Smethurst <31960611+tsmethurst@users.noreply.github.com>
Date:   Sun, 11 Jul 2021 16:22:21 +0200

Block/unblock (#96)

* remote + local block logic, incl. federation

* improve blocking stuff

* fiddle with display of blocked profiles

* go fmt
Diffstat:
MPROGRESS.md | 8++++----
Minternal/api/client/account/account.go | 34++++++++++++++++++++++++++++------
Ainternal/api/client/account/block.go | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/account/unblock.go | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/blocks/blocks.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/api/client/blocks/blocksget.go | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/api/client/streaming/stream.go | 14+++++++-------
Ainternal/api/model/block.go | 26++++++++++++++++++++++++++
Minternal/cliactions/server/server.go | 3+++
Minternal/cliactions/testrig/testrig.go | 3+++
Minternal/db/db.go | 2++
Ainternal/db/pg/blocks.go | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/federation/dereference.go | 2++
Minternal/federation/federatingdb/create.go | 30++++++++++++++++++++++++++++++
Minternal/federation/federatingdb/owns.go | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Minternal/federation/federatingdb/undo.go | 25+++++++++++++++++++++++++
Minternal/federation/federatingdb/util.go | 30++++++++++++++++++++++++++++--
Minternal/federation/federatingprotocol.go | 16+++++++++-------
Minternal/gtsmodel/block.go | 8+++++---
Minternal/gtsmodel/status.go | 2++
Minternal/processing/account.go | 8++++++++
Minternal/processing/account/account.go | 5+++++
Ainternal/processing/account/createblock.go | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/processing/account/get.go | 14++++++++++++--
Ainternal/processing/account/removeblock.go | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/processing/blocks.go | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/processing/fromclientapi.go | 117++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Minternal/processing/fromfederator.go | 24++++++++++++++++++++----
Minternal/processing/processor.go | 11+++++++++--
Minternal/timeline/get.go | 18++++++++++++++++++
Minternal/timeline/index.go | 37++++++++++++++++++++++++++++++-------
Minternal/timeline/manager.go | 16++++++++++++++--
Minternal/timeline/postindex.go | 24++++++++++++++++++++++--
Minternal/timeline/prepare.go | 25+++++++++++++++++++++++--
Minternal/timeline/preparedposts.go | 25+++++++++++++++++++++++--
Minternal/timeline/remove.go | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/timeline/timeline.go | 8++++++--
Minternal/typeutils/asinterfaces.go | 9+++++++++
Minternal/typeutils/astointernal.go | 35+++++++++++++++++++++++++++++++++++
Minternal/typeutils/converter.go | 13+++++++++++++
Minternal/typeutils/internal.go | 1+
Minternal/typeutils/internaltoas.go | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/typeutils/internaltofrontend.go | 33+++++++++++++++++++++++++++++++++
Minternal/util/regexes.go | 5+++++
Minternal/util/uri.go | 25+++++++++++++++++++++++++
45 files changed, 1405 insertions(+), 63 deletions(-)

diff --git a/PROGRESS.md b/PROGRESS.md @@ -56,8 +56,8 @@ Things are moving on the project! As of July 2021 you can now: * [ ] /api/v1/accounts/:id/identity_proofs GET (Get identity proofs for this account) * [x] /api/v1/accounts/:id/follow POST (Follow this account) * [x] /api/v1/accounts/:id/unfollow POST (Unfollow this account) - * [ ] /api/v1/accounts/:id/block POST (Block this account) - * [ ] /api/v1/accounts/:id/unblock POST (Unblock this account) + * [x] /api/v1/accounts/:id/block POST (Block this account) + * [x] /api/v1/accounts/:id/unblock POST (Unblock this account) * [ ] /api/v1/accounts/:id/mute POST (Mute this account) * [ ] /api/v1/accounts/:id/unmute POST (Unmute this account) * [ ] /api/v1/accounts/:id/pin POST (Feature this account on profile) @@ -71,8 +71,8 @@ Things are moving on the project! As of July 2021 you can now: * [x] /api/v1/favourites GET (See faved statuses) * [ ] Mutes * [ ] /api/v1/mutes GET (See list of muted accounts) - * [ ] Blocks - * [ ] /api/v1/blocks GET (See list of blocked accounts) + * [x] Blocks + * [x] /api/v1/blocks GET (See list of blocked accounts) * [ ] Domain Blocks * [x] /api/v1/domain_blocks GET (See list of domain blocks) * [x] /api/v1/domain_blocks POST (Create a domain block) diff --git a/internal/api/client/account/account.go b/internal/api/client/account/account.go @@ -61,10 +61,14 @@ const ( GetFollowingPath = BasePathWithID + "/following" // GetRelationshipsPath is for showing an account's relationship with other accounts GetRelationshipsPath = BasePath + "/relationships" - // PostFollowPath is for POSTing new follows to, and updating existing follows - PostFollowPath = BasePathWithID + "/follow" - // PostUnfollowPath is for POSTing an unfollow - PostUnfollowPath = BasePathWithID + "/unfollow" + // FollowPath is for POSTing new follows to, and updating existing follows + FollowPath = BasePathWithID + "/follow" + // UnfollowPath is for POSTing an unfollow + UnfollowPath = BasePathWithID + "/unfollow" + // BlockPath is for creating a block of an account + BlockPath = BasePathWithID + "/block" + // UnblockPath is for removing a block of an account + UnblockPath = BasePathWithID + "/unblock" ) // Module implements the ClientAPIModule interface for account-related actions @@ -85,15 +89,33 @@ func New(config *config.Config, processor processing.Processor, log *logrus.Logg // Route attaches all routes from this module to the given router func (m *Module) Route(r router.Router) error { + // create account r.AttachHandler(http.MethodPost, BasePath, m.AccountCreatePOSTHandler) + + // get account r.AttachHandler(http.MethodGet, BasePathWithID, m.muxHandler) + + // modify account r.AttachHandler(http.MethodPatch, BasePathWithID, m.muxHandler) + + // get account's statuses r.AttachHandler(http.MethodGet, GetStatusesPath, m.AccountStatusesGETHandler) + + // get following or followers r.AttachHandler(http.MethodGet, GetFollowersPath, m.AccountFollowersGETHandler) r.AttachHandler(http.MethodGet, GetFollowingPath, m.AccountFollowingGETHandler) + + // get relationship with account r.AttachHandler(http.MethodGet, GetRelationshipsPath, m.AccountRelationshipsGETHandler) - r.AttachHandler(http.MethodPost, PostFollowPath, m.AccountFollowPOSTHandler) - r.AttachHandler(http.MethodPost, PostUnfollowPath, m.AccountUnfollowPOSTHandler) + + // follow or unfollow account + r.AttachHandler(http.MethodPost, FollowPath, m.AccountFollowPOSTHandler) + r.AttachHandler(http.MethodPost, UnfollowPath, m.AccountUnfollowPOSTHandler) + + // block or unblock account + r.AttachHandler(http.MethodPost, BlockPath, m.AccountBlockPOSTHandler) + r.AttachHandler(http.MethodPost, UnblockPath, m.AccountUnblockPOSTHandler) + return nil } diff --git a/internal/api/client/account/block.go b/internal/api/client/account/block.go @@ -0,0 +1,49 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountBlockPOSTHandler handles the creation of a block from the authed account targeting the given account ID. +func (m *Module) AccountBlockPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + return + } + + relationship, errWithCode := m.processor.AccountBlockCreate(authed, targetAcctID) + if errWithCode != nil { + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + c.JSON(http.StatusOK, relationship) +} diff --git a/internal/api/client/account/unblock.go b/internal/api/client/account/unblock.go @@ -0,0 +1,49 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// AccountUnblockPOSTHandler handles the removal of a block from the authed account targeting the given account ID. +func (m *Module) AccountUnblockPOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + targetAcctID := c.Param(IDKey) + if targetAcctID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "no account id specified"}) + return + } + + relationship, errWithCode := m.processor.AccountBlockRemove(authed, targetAcctID) + if errWithCode != nil { + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + c.JSON(http.StatusOK, relationship) +} diff --git a/internal/api/client/blocks/blocks.go b/internal/api/client/blocks/blocks.go @@ -0,0 +1,63 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package blocks + +import ( + "net/http" + + "github.com/sirupsen/logrus" + "github.com/superseriousbusiness/gotosocial/internal/api" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/processing" + "github.com/superseriousbusiness/gotosocial/internal/router" +) + +const ( + // BasePath is the base URI path for serving favourites + BasePath = "/api/v1/blocks" + + // MaxIDKey is the url query for setting a max ID to return + MaxIDKey = "max_id" + // SinceIDKey is the url query for returning results newer than the given ID + SinceIDKey = "since_id" + // LimitKey is for specifying maximum number of results to return. + LimitKey = "limit" +) + +// Module implements the ClientAPIModule interface for everything relating to viewing blocks +type Module struct { + config *config.Config + processor processing.Processor + log *logrus.Logger +} + +// New returns a new blocks module +func New(config *config.Config, processor processing.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.BlocksGETHandler) + return nil +} diff --git a/internal/api/client/blocks/blocksget.go b/internal/api/client/blocks/blocksget.go @@ -0,0 +1,75 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package blocks + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// BlocksGETHandler handles GETting blocks. +func (m *Module) BlocksGETHandler(c *gin.Context) { + l := m.log.WithField("func", "PublicTimelineGETHandler") + + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + l.Debugf("error authing: %s", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + maxID := "" + maxIDString := c.Query(MaxIDKey) + if maxIDString != "" { + maxID = maxIDString + } + + sinceID := "" + sinceIDString := c.Query(SinceIDKey) + if sinceIDString != "" { + sinceID = sinceIDString + } + + 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) + } + + resp, errWithCode := m.processor.BlocksGet(authed, maxID, sinceID, limit) + if errWithCode != nil { + l.Debugf("error from processor BlocksGet: %s", errWithCode) + c.JSON(errWithCode.Code(), gin.H{"error": errWithCode.Safe()}) + return + } + + if resp.LinkHeader != "" { + c.Header("Link", resp.LinkHeader) + } + c.JSON(http.StatusOK, resp.Accounts) +} diff --git a/internal/api/client/streaming/stream.go b/internal/api/client/streaming/stream.go @@ -67,23 +67,23 @@ sendLoop: select { case m := <-stream.Messages: // we've got a streaming message!! - l.Debug("received message from stream") + l.Trace("received message from stream") if err := conn.WriteJSON(m); err != nil { - l.Infof("error writing json to websocket connection: %s", err) + l.Debugf("error writing json to websocket connection: %s", err) // if something is wrong we want to bail and drop the connection -- the client will create a new one break sendLoop } - l.Debug("wrote message into websocket connection") + l.Trace("wrote message into websocket connection") case <-t.C: - l.Debug("received TICK from ticker") + l.Trace("received TICK from ticker") if err := conn.WriteMessage(websocket.PingMessage, []byte(": ping")); err != nil { - l.Infof("error writing ping to websocket connection: %s", err) + l.Debugf("error writing ping to websocket connection: %s", err) // if something is wrong we want to bail and drop the connection -- the client will create a new one break sendLoop } - l.Debug("wrote ping message into websocket connection") + l.Trace("wrote ping message into websocket connection") } } - l.Debug("leaving StreamGETHandler") + l.Trace("leaving StreamGETHandler") } diff --git a/internal/api/model/block.go b/internal/api/model/block.go @@ -0,0 +1,26 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package model + +// BlocksResponse wraps a slice of accounts, ready to be serialized, along with the Link +// header for the previous and next queries, to be returned to the client. +type BlocksResponse struct { + Accounts []*Account + LinkHeader string +} diff --git a/internal/cliactions/server/server.go b/internal/cliactions/server/server.go @@ -14,6 +14,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/admin" "github.com/superseriousbusiness/gotosocial/internal/api/client/app" "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" + "github.com/superseriousbusiness/gotosocial/internal/api/client/blocks" "github.com/superseriousbusiness/gotosocial/internal/api/client/emoji" "github.com/superseriousbusiness/gotosocial/internal/api/client/favourites" "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" @@ -143,6 +144,7 @@ var Start cliactions.GTSAction = func(ctx context.Context, c *config.Config, log securityModule := security.New(c, dbService, log) streamingModule := streaming.New(c, processor, log) favouritesModule := favourites.New(c, processor, log) + blocksModule := blocks.New(c, processor, log) apis := []api.ClientModule{ // modules with middleware go first @@ -170,6 +172,7 @@ var Start cliactions.GTSAction = func(ctx context.Context, c *config.Config, log listsModule, streamingModule, favouritesModule, + blocksModule, } for _, m := range apis { diff --git a/internal/cliactions/testrig/testrig.go b/internal/cliactions/testrig/testrig.go @@ -16,6 +16,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/api/client/admin" "github.com/superseriousbusiness/gotosocial/internal/api/client/app" "github.com/superseriousbusiness/gotosocial/internal/api/client/auth" + "github.com/superseriousbusiness/gotosocial/internal/api/client/blocks" "github.com/superseriousbusiness/gotosocial/internal/api/client/emoji" "github.com/superseriousbusiness/gotosocial/internal/api/client/favourites" "github.com/superseriousbusiness/gotosocial/internal/api/client/fileserver" @@ -88,6 +89,7 @@ var Start cliactions.GTSAction = func(ctx context.Context, _ *config.Config, log securityModule := security.New(c, dbService, log) streamingModule := streaming.New(c, processor, log) favouritesModule := favourites.New(c, processor, log) + blocksModule := blocks.New(c, processor, log) apis := []api.ClientModule{ // modules with middleware go first @@ -115,6 +117,7 @@ var Start cliactions.GTSAction = func(ctx context.Context, _ *config.Config, log listsModule, streamingModule, favouritesModule, + blocksModule, } for _, m := range apis { diff --git a/internal/db/db.go b/internal/db/db.go @@ -159,6 +159,8 @@ type DB interface { // In case of no entries, a 'no entries' error will be returned GetStatusesForAccount(accountID string, limit int, excludeReplies bool, maxID string, pinnedOnly bool, mediaOnly bool) ([]*gtsmodel.Status, error) + GetBlocksForAccount(accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, error) + // GetLastStatusForAccountID simply gets the most recent status by the given account. // The given slice 'status' pointer will be set to the result of the query, whatever it is. // In case of no entries, a 'no entries' error will be returned diff --git a/internal/db/pg/blocks.go b/internal/db/pg/blocks.go @@ -0,0 +1,67 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package pg + +import ( + "github.com/go-pg/pg/v10" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (ps *postgresService) GetBlocksForAccount(accountID string, maxID string, sinceID string, limit int) ([]*gtsmodel.Account, string, string, error) { + blocks := []*gtsmodel.Block{} + + fq := ps.conn.Model(&blocks). + Where("block.account_id = ?", accountID). + Relation("TargetAccount"). + Order("block.id DESC") + + if maxID != "" { + fq = fq.Where("block.id < ?", maxID) + } + + if sinceID != "" { + fq = fq.Where("block.id > ?", sinceID) + } + + if limit > 0 { + fq = fq.Limit(limit) + } + + err := fq.Select() + if err != nil { + if err == pg.ErrNoRows { + return nil, "", "", db.ErrNoEntries{} + } + return nil, "", "", err + } + + if len(blocks) == 0 { + return nil, "", "", db.ErrNoEntries{} + } + + accounts := []*gtsmodel.Account{} + for _, b := range blocks { + accounts = append(accounts, b.TargetAccount) + } + + nextMaxID := blocks[len(blocks)-1].ID + prevMinID := blocks[0].ID + return accounts, nextMaxID, prevMinID, nil +} diff --git a/internal/federation/dereference.go b/internal/federation/dereference.go @@ -393,6 +393,7 @@ func (f *federator) DereferenceAnnounce(announce *gtsmodel.Status, requestingUse announce.Language = boostedStatus.Language announce.Text = boostedStatus.Text announce.BoostOfID = boostedStatus.ID + announce.BoostOfAccountID = boostedStatus.AccountID announce.Visibility = boostedStatus.Visibility announce.VisibilityAdvanced = boostedStatus.VisibilityAdvanced announce.GTSBoostedStatus = boostedStatus @@ -477,6 +478,7 @@ func (f *federator) DereferenceAnnounce(announce *gtsmodel.Status, requestingUse announce.Language = boostedStatus.Language announce.Text = boostedStatus.Text announce.BoostOfID = boostedStatus.ID + announce.BoostOfAccountID = boostedStatus.AccountID announce.Visibility = boostedStatus.Visibility announce.VisibilityAdvanced = boostedStatus.VisibilityAdvanced announce.GTSBoostedStatus = boostedStatus diff --git a/internal/federation/federatingdb/create.go b/internal/federation/federatingdb/create.go @@ -129,6 +129,7 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { } } case gtsmodel.ActivityStreamsFollow: + // FOLLOW SOMETHING follow, ok := asType.(vocab.ActivityStreamsFollow) if !ok { return errors.New("could not convert type to follow") @@ -156,6 +157,7 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { ReceivingAccount: targetAcct, } case gtsmodel.ActivityStreamsLike: + // LIKE SOMETHING like, ok := asType.(vocab.ActivityStreamsLike) if !ok { return errors.New("could not convert type to like") @@ -182,6 +184,34 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { GTSModel: fave, ReceivingAccount: targetAcct, } + case gtsmodel.ActivityStreamsBlock: + // BLOCK SOMETHING + blockable, ok := asType.(vocab.ActivityStreamsBlock) + if !ok { + return errors.New("could not convert type to block") + } + + block, err := f.typeConverter.ASBlockToBlock(blockable) + if err != nil { + return fmt.Errorf("could not convert Block to gts model block") + } + + newID, err := id.NewULID() + if err != nil { + return err + } + block.ID = newID + + if err := f.db.Put(block); err != nil { + return fmt.Errorf("database error inserting block: %s", err) + } + + fromFederatorChan <- gtsmodel.FromFederator{ + APObjectType: gtsmodel.ActivityStreamsBlock, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: block, + ReceivingAccount: targetAcct, + } } return nil } diff --git a/internal/federation/federatingdb/owns.go b/internal/federation/federatingdb/owns.go @@ -39,16 +39,15 @@ func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) { "id": id.String(), }, ) - l.Debugf("entering OWNS function with id %s", id.String()) + l.Tracef("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) + l.Tracef("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) @@ -63,11 +62,10 @@ func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) { // an actual error happened return false, fmt.Errorf("database error fetching status with id %s: %s", uid, err) } - l.Debug("we DO own this") + l.Debugf("we own url %s", id.String()) 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 { @@ -81,7 +79,7 @@ func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) { // an actual error happened return false, fmt.Errorf("database error fetching account with username %s: %s", username, err) } - l.Debug("we DO own this") + l.Debugf("we own url %s", id.String()) return true, nil } @@ -98,7 +96,7 @@ func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) { // an actual error happened return false, fmt.Errorf("database error fetching account with username %s: %s", username, err) } - l.Debug("we DO own this") + l.Debugf("we own url %s", id.String()) return true, nil } @@ -115,7 +113,57 @@ func (f *federatingDB) Owns(c context.Context, id *url.URL) (bool, error) { // an actual error happened return false, fmt.Errorf("database error fetching account with username %s: %s", username, err) } - l.Debug("we DO own this") + l.Debugf("we own url %s", id.String()) + return true, nil + } + + if util.IsLikePath(id) { + username, likeID, err := util.ParseLikedPath(id) + if err != nil { + return false, fmt.Errorf("error parsing like path for url %s: %s", id.String(), err) + } + if err := f.db.GetLocalAccountByUsername(username, &gtsmodel.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) + } + if err := f.db.GetByID(likeID, &gtsmodel.StatusFave{}); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // there are no entries + return false, nil + } + // an actual error happened + return false, fmt.Errorf("database error fetching like with id %s: %s", likeID, err) + } + l.Debugf("we own url %s", id.String()) + return true, nil + } + + if util.IsBlockPath(id) { + username, blockID, err := util.ParseBlockPath(id) + if err != nil { + return false, fmt.Errorf("error parsing block path for url %s: %s", id.String(), err) + } + if err := f.db.GetLocalAccountByUsername(username, &gtsmodel.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) + } + if err := f.db.GetByID(blockID, &gtsmodel.Block{}); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // there are no entries + return false, nil + } + // an actual error happened + return false, fmt.Errorf("database error fetching block with id %s: %s", blockID, err) + } + l.Debugf("we own url %s", id.String()) return true, nil } diff --git a/internal/federation/federatingdb/undo.go b/internal/federation/federatingdb/undo.go @@ -85,6 +85,31 @@ func (f *federatingDB) Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) // UNDO LIKE case string(gtsmodel.ActivityStreamsAnnounce): // UNDO BOOST/REBLOG/ANNOUNCE + case string(gtsmodel.ActivityStreamsBlock): + // UNDO BLOCK + ASBlock, ok := iter.GetType().(vocab.ActivityStreamsBlock) + if !ok { + return errors.New("UNDO: couldn't parse block into vocab.ActivityStreamsBlock") + } + // make sure the actor owns the follow + if !sameActor(undo.GetActivityStreamsActor(), ASBlock.GetActivityStreamsActor()) { + return errors.New("UNDO: block actor and activity actor not the same") + } + // convert the block to something we can understand + gtsBlock, err := f.typeConverter.ASBlockToBlock(ASBlock) + if err != nil { + return fmt.Errorf("UNDO: error converting asblock to gtsblock: %s", err) + } + // make sure the addressee of the original block is the same as whatever inbox this landed in + if gtsBlock.TargetAccountID != targetAcct.ID { + return errors.New("UNDO: block object account and inbox account were not the same") + } + // delete any existing BLOCK + if err := f.db.DeleteWhere([]db.Where{{Key: "uri", Value: gtsBlock.URI}}, &gtsmodel.Block{}); err != nil { + return fmt.Errorf("UNDO: db error removing block: %s", err) + } + l.Debug("block undone") + return nil } } diff --git a/internal/federation/federatingdb/util.go b/internal/federation/federatingdb/util.go @@ -139,7 +139,7 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (idURL *url.URL, e // ID might already be set on an announce we've created, so check it here and return it if it is announce, ok := t.(vocab.ActivityStreamsAnnounce) if !ok { - return nil, errors.New("newid: fave couldn't be parsed into vocab.ActivityStreamsAnnounce") + return nil, errors.New("newid: announce couldn't be parsed into vocab.ActivityStreamsAnnounce") } idProp := announce.GetJSONLDId() if idProp != nil { @@ -152,7 +152,7 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (idURL *url.URL, e // ID might already be set on an update we've created, so check it here and return it if it is update, ok := t.(vocab.ActivityStreamsUpdate) if !ok { - return nil, errors.New("newid: fave couldn't be parsed into vocab.ActivityStreamsUpdate") + return nil, errors.New("newid: update couldn't be parsed into vocab.ActivityStreamsUpdate") } idProp := update.GetJSONLDId() if idProp != nil { @@ -160,6 +160,32 @@ func (f *federatingDB) NewID(c context.Context, t vocab.Type) (idURL *url.URL, e return idProp.GetIRI(), nil } } + case gtsmodel.ActivityStreamsBlock: + // BLOCK + // ID might already be set on a block we've created, so check it here and return it if it is + block, ok := t.(vocab.ActivityStreamsBlock) + if !ok { + return nil, errors.New("newid: block couldn't be parsed into vocab.ActivityStreamsBlock") + } + idProp := block.GetJSONLDId() + if idProp != nil { + if idProp.IsIRI() { + return idProp.GetIRI(), nil + } + } + case gtsmodel.ActivityStreamsUndo: + // UNDO + // ID might already be set on an undo we've created, so check it here and return it if it is + undo, ok := t.(vocab.ActivityStreamsUndo) + if !ok { + return nil, errors.New("newid: undo couldn't be parsed into vocab.ActivityStreamsUndo") + } + idProp := undo.GetJSONLDId() + if idProp != nil { + if idProp.IsIRI() { + return idProp.GetIRI(), nil + } + } } // fallback default behavior: just return a random ULID after our protocol and host diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go @@ -243,8 +243,8 @@ func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, er return true, nil } - a := &gtsmodel.Account{} - if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: uri.String()}}, a); err != nil { + requestingAccount := &gtsmodel.Account{} + if err := f.db.GetWhere([]db.Where{{Key: "uri", Value: uri.String()}}, requestingAccount); err != nil { _, ok := err.(db.ErrNoEntries) if ok { // we don't have an entry for this account so it's not blocked @@ -253,11 +253,13 @@ func (f *federator) Blocked(ctx context.Context, actorIRIs []*url.URL) (bool, er } return false, fmt.Errorf("error getting account with uri %s: %s", uri.String(), err) } - blocked, err := f.db.Blocked(requestedAccount.ID, a.ID) - if err != nil { - return false, fmt.Errorf("error checking account blocks: %s", err) - } - if blocked { + + // check if requested account blocks requesting account + if err := f.db.GetWhere([]db.Where{ + {Key: "account_id", Value: requestedAccount.ID}, + {Key: "target_account_id", Value: requestingAccount.ID}, + }, &gtsmodel.Block{}); err == nil { + // a block exists return true, nil } } diff --git a/internal/gtsmodel/block.go b/internal/gtsmodel/block.go @@ -11,9 +11,11 @@ type Block struct { // When was this block updated UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` // Who created this block? - AccountID string `pg:"type:CHAR(26),notnull"` + AccountID string `pg:"type:CHAR(26),notnull"` + Account *Account `pg:"rel:has-one"` // Who is targeted by this block? - TargetAccountID string `pg:"type:CHAR(26),notnull"` + TargetAccountID string `pg:"type:CHAR(26),notnull"` + TargetAccount *Account `pg:"rel:has-one"` // Activitypub URI for this block - URI string + URI string `pg:",notnull"` } diff --git a/internal/gtsmodel/status.go b/internal/gtsmodel/status.go @@ -56,6 +56,8 @@ type Status struct { InReplyToAccountID string `pg:"type:CHAR(26)"` // id of the status this status is a boost of BoostOfID string `pg:"type:CHAR(26)"` + // id of the account that owns the boosted status + BoostOfAccountID string `pg:"type:CHAR(26)"` // cw string for this status ContentWarning string // visibility entry for this status diff --git a/internal/processing/account.go b/internal/processing/account.go @@ -59,3 +59,11 @@ func (p *processor) AccountFollowCreate(authed *oauth.Auth, form *apimodel.Accou func (p *processor) AccountFollowRemove(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { return p.accountProcessor.FollowRemove(authed.Account, targetAccountID) } + +func (p *processor) AccountBlockCreate(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { + return p.accountProcessor.BlockCreate(authed.Account, targetAccountID) +} + +func (p *processor) AccountBlockRemove(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { + return p.accountProcessor.BlockRemove(authed.Account, targetAccountID) +} diff --git a/internal/processing/account/account.go b/internal/processing/account/account.go @@ -59,6 +59,11 @@ type Processor interface { FollowCreate(requestingAccount *gtsmodel.Account, form *apimodel.AccountFollowRequest) (*apimodel.Relationship, gtserror.WithCode) // FollowRemove handles the removal of a follow/follow request to an account, either remote or local. FollowRemove(requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) + // BlockCreate handles the creation of a block from requestingAccount to targetAccountID, either remote or local. + BlockCreate(requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) + // BlockRemove handles the removal of a block from requestingAccount to targetAccountID, either remote or local. + BlockRemove(requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) + // UpdateHeader does the dirty work of checking the header part of an account update form, // parsing and checking the image, and doing the necessary updates in the database for this to become // the account's new header image. diff --git a/internal/processing/account/createblock.go b/internal/processing/account/createblock.go @@ -0,0 +1,155 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" + "github.com/superseriousbusiness/gotosocial/internal/util" +) + +func (p *processor) BlockCreate(requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { + // make sure the target account actually exists in our db + targetAcct := &gtsmodel.Account{} + if err := p.db.GetByID(targetAccountID, targetAcct); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("BlockCreate: account %s not found in the db: %s", targetAccountID, err)) + } + } + + // if requestingAccount already blocks target account, we don't need to do anything + block := &gtsmodel.Block{} + if err := p.db.GetWhere([]db.Where{ + {Key: "account_id", Value: requestingAccount.ID}, + {Key: "target_account_id", Value: targetAccountID}, + }, block); err == nil { + // block already exists, just return relationship + return p.RelationshipGet(requestingAccount, targetAccountID) + } + + // make the block + newBlockID, err := id.NewULID() + if err != nil { + return nil, gtserror.NewErrorInternalError(err) + } + block.ID = newBlockID + block.AccountID = requestingAccount.ID + block.Account = requestingAccount + block.TargetAccountID = targetAccountID + block.TargetAccount = targetAcct + block.URI = util.GenerateURIForBlock(requestingAccount.Username, p.config.Protocol, p.config.Host, newBlockID) + + // whack it in the database + if err := p.db.Put(block); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockCreate: error creating block in db: %s", err)) + } + + // clear any follows or follow requests from the blocked account to the target account -- this is a simple delete + if err := p.db.DeleteWhere([]db.Where{ + {Key: "account_id", Value: targetAccountID}, + {Key: "target_account_id", Value: requestingAccount.ID}, + }, &gtsmodel.Follow{}); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockCreate: error removing follow in db: %s", err)) + } + if err := p.db.DeleteWhere([]db.Where{ + {Key: "account_id", Value: targetAccountID}, + {Key: "target_account_id", Value: requestingAccount.ID}, + }, &gtsmodel.FollowRequest{}); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockCreate: error removing follow in db: %s", err)) + } + + // clear any follows or follow requests from the requesting account to the target account -- + // this might require federation so we need to pass some messages around + + // check if a follow request exists from the requesting account to the target account, and remove it if it does (storing the URI for later) + var frChanged bool + var frURI string + fr := &gtsmodel.FollowRequest{} + if err := p.db.GetWhere([]db.Where{ + {Key: "account_id", Value: requestingAccount.ID}, + {Key: "target_account_id", Value: targetAccountID}, + }, fr); err == nil { + frURI = fr.URI + if err := p.db.DeleteByID(fr.ID, fr); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockCreate: error removing follow request from db: %s", err)) + } + frChanged = true + } + + // now do the same thing for any existing follow + var fChanged bool + var fURI string + f := &gtsmodel.Follow{} + if err := p.db.GetWhere([]db.Where{ + {Key: "account_id", Value: requestingAccount.ID}, + {Key: "target_account_id", Value: targetAccountID}, + }, f); err == nil { + fURI = f.URI + if err := p.db.DeleteByID(f.ID, f); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockCreate: error removing follow from db: %s", err)) + } + fChanged = true + } + + // follow request status changed so send the UNDO activity to the channel for async processing + if frChanged { + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsFollow, + APActivityType: gtsmodel.ActivityStreamsUndo, + GTSModel: &gtsmodel.Follow{ + AccountID: requestingAccount.ID, + TargetAccountID: targetAccountID, + URI: frURI, + }, + OriginAccount: requestingAccount, + TargetAccount: targetAcct, + } + } + + // follow status changed so send the UNDO activity to the channel for async processing + if fChanged { + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsFollow, + APActivityType: gtsmodel.ActivityStreamsUndo, + GTSModel: &gtsmodel.Follow{ + AccountID: requestingAccount.ID, + TargetAccountID: targetAccountID, + URI: fURI, + }, + OriginAccount: requestingAccount, + TargetAccount: targetAcct, + } + } + + // handle the rest of the block process asynchronously + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsBlock, + APActivityType: gtsmodel.ActivityStreamsCreate, + GTSModel: block, + OriginAccount: requestingAccount, + TargetAccount: targetAcct, + } + + return p.RelationshipGet(requestingAccount, targetAccountID) +} diff --git a/internal/processing/account/get.go b/internal/processing/account/get.go @@ -45,9 +45,19 @@ func (p *processor) Get(requestingAccount *gtsmodel.Account, targetAccountID str p.log.WithField("func", "AccountGet").Debugf("dereferencing account: %s", err) } - var mastoAccount *apimodel.Account + var blocked bool var err error - if requestingAccount != nil && targetAccount.ID == requestingAccount.ID { + if requestingAccount != nil { + blocked, err = p.db.Blocked(requestingAccount.ID, targetAccountID) + if err != nil { + return nil, fmt.Errorf("error checking account block: %s", err) + } + } + + var mastoAccount *apimodel.Account + if blocked { + mastoAccount, err = p.tc.AccountToMastoBlocked(targetAccount) + } else if requestingAccount != nil && targetAccount.ID == requestingAccount.ID { mastoAccount, err = p.tc.AccountToMastoSensitive(targetAccount) } else { mastoAccount, err = p.tc.AccountToMastoPublic(targetAccount) diff --git a/internal/processing/account/removeblock.go b/internal/processing/account/removeblock.go @@ -0,0 +1,67 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + +package account + +import ( + "fmt" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +) + +func (p *processor) BlockRemove(requestingAccount *gtsmodel.Account, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) { + // make sure the target account actually exists in our db + targetAcct := &gtsmodel.Account{} + if err := p.db.GetByID(targetAccountID, targetAcct); err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + return nil, gtserror.NewErrorNotFound(fmt.Errorf("BlockRemove: account %s not found in the db: %s", targetAccountID, err)) + } + } + + // check if a block exists, and remove it if it does (storing the URI for later) + var blockChanged bool + block := &gtsmodel.Block{} + if err := p.db.GetWhere([]db.Where{ + {Key: "account_id", Value: requestingAccount.ID}, + {Key: "target_account_id", Value: targetAccountID}, + }, block); err == nil { + block.Account = requestingAccount + block.TargetAccount = targetAcct + if err := p.db.DeleteByID(block.ID, &gtsmodel.Block{}); err != nil { + return nil, gtserror.NewErrorInternalError(fmt.Errorf("BlockRemove: error removing block from db: %s", err)) + } + blockChanged = true + } + + // block status changed so send the UNDO activity to the channel for async processing + if blockChanged { + p.fromClientAPI <- gtsmodel.FromClientAPI{ + APObjectType: gtsmodel.ActivityStreamsBlock, + APActivityType: gtsmodel.ActivityStreamsUndo, + GTSModel: block, + OriginAccount: requestingAccount, + TargetAccount: targetAcct, + } + } + + // return whatever relationship results from all this + return p.RelationshipGet(requestingAccount, targetAccountID) +} diff --git a/internal/processing/blocks.go b/internal/processing/blocks.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 <http://www.gnu.org/licenses/>. +*/ + +package processing + +import ( + "fmt" + "net/url" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +func (p *processor) BlocksGet(authed *oauth.Auth, maxID string, sinceID string, limit int) (*apimodel.BlocksResponse, gtserror.WithCode) { + accounts, nextMaxID, prevMinID, err := p.db.GetBlocksForAccount(authed.Account.ID, maxID, sinceID, limit) + if err != nil { + if _, ok := err.(db.ErrNoEntries); ok { + // there are just no entries + return &apimodel.BlocksResponse{ + Accounts: []*apimodel.Account{}, + }, nil + } + // there's an actual error + return nil, gtserror.NewErrorInternalError(err) + } + + apiAccounts := []*apimodel.Account{} + for _, a := range accounts { + apiAccount, err := p.tc.AccountToMastoBlocked(a) + if err != nil { + continue + } + apiAccounts = append(apiAccounts, apiAccount) + } + + return p.packageBlocksResponse(apiAccounts, "/api/v1/blocks", nextMaxID, prevMinID, limit) +} + +func (p *processor) packageBlocksResponse(accounts []*apimodel.Account, path string, nextMaxID string, prevMinID string, limit int) (*apimodel.BlocksResponse, gtserror.WithCode) { + resp := &apimodel.BlocksResponse{ + Accounts: []*apimodel.Account{}, + } + resp.Accounts = accounts + + // prepare the next and previous links + if len(accounts) != 0 { + nextLink := &url.URL{ + Scheme: p.config.Protocol, + Host: p.config.Host, + Path: path, + RawQuery: fmt.Sprintf("limit=%d&max_id=%s", limit, nextMaxID), + } + next := fmt.Sprintf("<%s>; rel=\"next\"", nextLink.String()) + + prevLink := &url.URL{ + Scheme: p.config.Protocol, + Host: p.config.Host, + Path: path, + RawQuery: fmt.Sprintf("limit=%d&min_id=%s", limit, prevMinID), + } + prev := fmt.Sprintf("<%s>; rel=\"prev\"", prevLink.String()) + resp.LinkHeader = fmt.Sprintf("%s, %s", next, prev) + } + + return resp, nil +} diff --git a/internal/processing/fromclientapi.go b/internal/processing/fromclientapi.go @@ -76,7 +76,6 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error } return p.federateFave(fave, clientMsg.OriginAccount, clientMsg.TargetAccount) - case gtsmodel.ActivityStreamsAnnounce: // CREATE BOOST/ANNOUNCE boostWrapperStatus, ok := clientMsg.GTSModel.(*gtsmodel.Status) @@ -93,6 +92,25 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error } return p.federateAnnounce(boostWrapperStatus, clientMsg.OriginAccount, clientMsg.TargetAccount) + case gtsmodel.ActivityStreamsBlock: + // CREATE BLOCK + block, ok := clientMsg.GTSModel.(*gtsmodel.Block) + if !ok { + return errors.New("block was not parseable as *gtsmodel.Block") + } + + // remove any of the blocking account's statuses from the blocked account's timeline, and vice versa + if err := p.timelineManager.WipeStatusesFromAccountID(block.AccountID, block.TargetAccountID); err != nil { + return err + } + if err := p.timelineManager.WipeStatusesFromAccountID(block.TargetAccountID, block.AccountID); err != nil { + return err + } + + // TODO: same with notifications + // TODO: same with bookmarks + + return p.federateBlock(block) } case gtsmodel.ActivityStreamsUpdate: // UPDATE @@ -132,6 +150,13 @@ func (p *processor) processFromClientAPI(clientMsg gtsmodel.FromClientAPI) error return errors.New("undo was not parseable as *gtsmodel.Follow") } return p.federateUnfollow(follow, clientMsg.OriginAccount, clientMsg.TargetAccount) + case gtsmodel.ActivityStreamsBlock: + // UNDO BLOCK + block, ok := clientMsg.GTSModel.(*gtsmodel.Block) + if !ok { + return errors.New("undo was not parseable as *gtsmodel.Block") + } + return p.federateUnblock(block) case gtsmodel.ActivityStreamsLike: // UNDO LIKE/FAVE fave, ok := clientMsg.GTSModel.(*gtsmodel.StatusFave) @@ -530,3 +555,93 @@ func (p *processor) federateAccountUpdate(updatedAccount *gtsmodel.Account, orig _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, update) return err } + +func (p *processor) federateBlock(block *gtsmodel.Block) error { + if block.Account == nil { + a := &gtsmodel.Account{} + if err := p.db.GetByID(block.AccountID, a); err != nil { + return fmt.Errorf("federateBlock: error getting block account from database: %s", err) + } + block.Account = a + } + + if block.TargetAccount == nil { + a := &gtsmodel.Account{} + if err := p.db.GetByID(block.TargetAccountID, a); err != nil { + return fmt.Errorf("federateBlock: error getting block target account from database: %s", err) + } + block.TargetAccount = a + } + + // if both accounts are local there's nothing to do here + if block.Account.Domain == "" && block.TargetAccount.Domain == "" { + return nil + } + + asBlock, err := p.tc.BlockToAS(block) + if err != nil { + return fmt.Errorf("federateBlock: error converting block to AS format: %s", err) + } + + outboxIRI, err := url.Parse(block.Account.OutboxURI) + if err != nil { + return fmt.Errorf("federateBlock: error parsing outboxURI %s: %s", block.Account.OutboxURI, err) + } + + _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, asBlock) + return err +} + +func (p *processor) federateUnblock(block *gtsmodel.Block) error { + if block.Account == nil { + a := &gtsmodel.Account{} + if err := p.db.GetByID(block.AccountID, a); err != nil { + return fmt.Errorf("federateUnblock: error getting block account from database: %s", err) + } + block.Account = a + } + + if block.TargetAccount == nil { + a := &gtsmodel.Account{} + if err := p.db.GetByID(block.TargetAccountID, a); err != nil { + return fmt.Errorf("federateUnblock: error getting block target account from database: %s", err) + } + block.TargetAccount = a + } + + // if both accounts are local there's nothing to do here + if block.Account.Domain == "" && block.TargetAccount.Domain == "" { + return nil + } + + asBlock, err := p.tc.BlockToAS(block) + if err != nil { + return fmt.Errorf("federateUnblock: error converting block to AS format: %s", err) + } + + targetAccountURI, err := url.Parse(block.TargetAccount.URI) + if err != nil { + return fmt.Errorf("federateUnblock: error parsing uri %s: %s", block.TargetAccount.URI, err) + } + + // create an Undo and set the appropriate actor on it + undo := streams.NewActivityStreamsUndo() + undo.SetActivityStreamsActor(asBlock.GetActivityStreamsActor()) + + // Set the block as the 'object' property. + undoObject := streams.NewActivityStreamsObjectProperty() + undoObject.AppendActivityStreamsBlock(asBlock) + undo.SetActivityStreamsObject(undoObject) + + // Set the To of the undo as the target of the block + undoTo := streams.NewActivityStreamsToProperty() + undoTo.AppendIRI(targetAccountURI) + undo.SetActivityStreamsTo(undoTo) + + outboxIRI, err := url.Parse(block.Account.OutboxURI) + if err != nil { + return fmt.Errorf("federateUnblock: error parsing outboxURI %s: %s", block.Account.OutboxURI, err) + } + _, err = p.federator.FederatingActor().Send(context.Background(), outboxIRI, undo) + return err +} diff --git a/internal/processing/fromfederator.go b/internal/processing/fromfederator.go @@ -34,7 +34,7 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er "federatorMsg": fmt.Sprintf("%+v", federatorMsg), }) - l.Debug("entering function PROCESS FROM FEDERATOR") + l.Trace("entering function PROCESS FROM FEDERATOR") switch federatorMsg.APActivityType { case gtsmodel.ActivityStreamsCreate: @@ -47,7 +47,7 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er return errors.New("note was not parseable as *gtsmodel.Status") } - l.Debug("will now derefence incoming status") + l.Trace("will now derefence incoming status") if err := p.federator.DereferenceStatusFields(incomingStatus, federatorMsg.ReceivingAccount.Username); err != nil { return fmt.Errorf("error dereferencing status from federator: %s", err) } @@ -70,7 +70,7 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er return errors.New("profile was not parseable as *gtsmodel.Account") } - l.Debug("will now derefence incoming account") + l.Trace("will now derefence incoming account") if err := p.federator.DereferenceAccountFields(incomingAccount, "", false); err != nil { return fmt.Errorf("error dereferencing account from federator: %s", err) } @@ -127,6 +127,22 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er if err := p.notifyAnnounce(incomingAnnounce); err != nil { return err } + case gtsmodel.ActivityStreamsBlock: + // CREATE A BLOCK + block, ok := federatorMsg.GTSModel.(*gtsmodel.Block) + if !ok { + return errors.New("block was not parseable as *gtsmodel.Block") + } + + // remove any of the blocking account's statuses from the blocked account's timeline, and vice versa + if err := p.timelineManager.WipeStatusesFromAccountID(block.AccountID, block.TargetAccountID); err != nil { + return err + } + if err := p.timelineManager.WipeStatusesFromAccountID(block.TargetAccountID, block.AccountID); err != nil { + return err + } + // TODO: same with notifications + // TODO: same with bookmarks } case gtsmodel.ActivityStreamsUpdate: // UPDATE @@ -138,7 +154,7 @@ func (p *processor) processFromFederator(federatorMsg gtsmodel.FromFederator) er return errors.New("profile was not parseable as *gtsmodel.Account") } - l.Debug("will now derefence incoming account") + l.Trace("will now derefence incoming account") if err := p.federator.DereferenceAccountFields(incomingAccount, federatorMsg.ReceivingAccount.Username, true); err != nil { return fmt.Errorf("error dereferencing account from federator: %s", err) } diff --git a/internal/processing/processor.go b/internal/processing/processor.go @@ -82,6 +82,10 @@ type Processor interface { AccountFollowCreate(authed *oauth.Auth, form *apimodel.AccountFollowRequest) (*apimodel.Relationship, gtserror.WithCode) // AccountFollowRemove handles the removal of a follow/follow request to an account, either remote or local. AccountFollowRemove(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) + // AccountBlockCreate handles the creation of a block from authed account to target account, either remote or local. + AccountBlockCreate(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) + // AccountBlockRemove handles the removal of a block from authed account to target account, either remote or local. + AccountBlockRemove(authed *oauth.Auth, targetAccountID string) (*apimodel.Relationship, gtserror.WithCode) // AdminEmojiCreate handles the creation of a new instance emoji by an admin, using the given form. AdminEmojiCreate(authed *oauth.Auth, form *apimodel.EmojiCreateRequest) (*apimodel.Emoji, error) @@ -99,6 +103,9 @@ type Processor interface { // AppCreate processes the creation of a new API application AppCreate(authed *oauth.Auth, form *apimodel.ApplicationCreateRequest) (*apimodel.Application, error) + // BlocksGet returns a list of accounts blocked by the requesting account. + BlocksGet(authed *oauth.Auth, maxID string, sinceID string, limit int) (*apimodel.BlocksResponse, gtserror.WithCode) + // FileGet handles the fetching of a media attachment file via the fileserver. FileGet(authed *oauth.Auth, form *apimodel.GetContentRequestForm) (*apimodel.Content, error) @@ -275,14 +282,14 @@ func (p *processor) Start() error { for { select { case clientMsg := <-p.fromClientAPI: - p.log.Infof("received message FROM client API: %+v", clientMsg) + p.log.Tracef("received message FROM client API: %+v", clientMsg) go func() { if err := p.processFromClientAPI(clientMsg); err != nil { p.log.Error(err) } }() case federatorMsg := <-p.fromFederator: - p.log.Infof("received message FROM federator: %+v", federatorMsg) + p.log.Tracef("received message FROM federator: %+v", federatorMsg) go func() { if err := p.processFromFederator(federatorMsg); err != nil { p.log.Error(err) diff --git a/internal/timeline/get.go b/internal/timeline/get.go @@ -1,3 +1,21 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + package timeline import ( diff --git a/internal/timeline/index.go b/internal/timeline/index.go @@ -1,3 +1,21 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + package timeline import ( @@ -44,7 +62,7 @@ grabloop: } for _, s := range filtered { - if _, err := t.IndexOne(s.CreatedAt, s.ID, s.BoostOfID); err != nil { + if _, err := t.IndexOne(s.CreatedAt, s.ID, s.BoostOfID, s.AccountID, s.BoostOfAccountID); err != nil { return fmt.Errorf("IndexBefore: error indexing status with id %s: %s", s.ID, err) } } @@ -79,7 +97,7 @@ grabloop: } for _, s := range filtered { - if _, err := t.IndexOne(s.CreatedAt, s.ID, s.BoostOfID); err != nil { + if _, err := t.IndexOne(s.CreatedAt, s.ID, s.BoostOfID, s.AccountID, s.BoostOfAccountID); err != nil { return fmt.Errorf("IndexBehind: error indexing status with id %s: %s", s.ID, err) } } @@ -91,24 +109,29 @@ func (t *timeline) IndexOneByID(statusID string) error { return nil } -func (t *timeline) IndexOne(statusCreatedAt time.Time, statusID string, boostOfID string) (bool, error) { +func (t *timeline) IndexOne(statusCreatedAt time.Time, statusID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) { t.Lock() defer t.Unlock() postIndexEntry := &postIndexEntry{ - statusID: statusID, - boostOfID: boostOfID, + statusID: statusID, + boostOfID: boostOfID, + accountID: accountID, + boostOfAccountID: boostOfAccountID, } return t.postIndex.insertIndexed(postIndexEntry) } -func (t *timeline) IndexAndPrepareOne(statusCreatedAt time.Time, statusID string) (bool, error) { +func (t *timeline) IndexAndPrepareOne(statusCreatedAt time.Time, statusID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) { t.Lock() defer t.Unlock() postIndexEntry := &postIndexEntry{ - statusID: statusID, + statusID: statusID, + boostOfID: boostOfID, + accountID: accountID, + boostOfAccountID: boostOfAccountID, } inserted, err := t.postIndex.insertIndexed(postIndexEntry) diff --git a/internal/timeline/manager.go b/internal/timeline/manager.go @@ -78,6 +78,8 @@ type Manager interface { Remove(statusID string, timelineAccountID string) (int, error) // WipeStatusFromAllTimelines removes one status from the index and prepared posts of all timelines WipeStatusFromAllTimelines(statusID string) error + // WipeStatusesFromAccountID removes all statuses by the given accountID from the timelineAccountID's timelines. + WipeStatusesFromAccountID(accountID string, timelineAccountID string) error } // NewManager returns a new timeline manager with the given database, typeconverter, config, and log. @@ -112,7 +114,7 @@ func (m *manager) Ingest(status *gtsmodel.Status, timelineAccountID string) (boo } l.Trace("ingesting status") - return t.IndexOne(status.CreatedAt, status.ID, status.BoostOfID) + return t.IndexOne(status.CreatedAt, status.ID, status.BoostOfID, status.AccountID, status.BoostOfAccountID) } func (m *manager) IngestAndPrepare(status *gtsmodel.Status, timelineAccountID string) (bool, error) { @@ -128,7 +130,7 @@ func (m *manager) IngestAndPrepare(status *gtsmodel.Status, timelineAccountID st } l.Trace("ingesting status") - return t.IndexAndPrepareOne(status.CreatedAt, status.ID) + return t.IndexAndPrepareOne(status.CreatedAt, status.ID, status.BoostOfID, status.AccountID, status.BoostOfAccountID) } func (m *manager) Remove(statusID string, timelineAccountID string) (int, error) { @@ -219,6 +221,16 @@ func (m *manager) WipeStatusFromAllTimelines(statusID string) error { return err } +func (m *manager) WipeStatusesFromAccountID(accountID string, timelineAccountID string) error { + t, err := m.getOrCreateTimeline(timelineAccountID) + if err != nil { + return err + } + + _, err = t.RemoveAllBy(accountID) + return err +} + func (m *manager) getOrCreateTimeline(timelineAccountID string) (Timeline, error) { var t Timeline i, ok := m.accountTimelines.Load(timelineAccountID) diff --git a/internal/timeline/postindex.go b/internal/timeline/postindex.go @@ -1,3 +1,21 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + package timeline import ( @@ -10,8 +28,10 @@ type postIndex struct { } type postIndexEntry struct { - statusID string - boostOfID string + statusID string + boostOfID string + accountID string + boostOfAccountID string } func (p *postIndex) insertIndexed(i *postIndexEntry) (bool, error) { diff --git a/internal/timeline/prepare.go b/internal/timeline/prepare.go @@ -1,3 +1,21 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + package timeline import ( @@ -207,8 +225,11 @@ func (t *timeline) prepare(statusID string) error { // shove it in prepared posts as a prepared posts entry preparedPostsEntry := &preparedPostsEntry{ - statusID: statusID, - prepared: apiModelStatus, + statusID: gtsStatus.ID, + boostOfID: gtsStatus.BoostOfID, + accountID: gtsStatus.AccountID, + boostOfAccountID: gtsStatus.BoostOfAccountID, + prepared: apiModelStatus, } return t.preparedPosts.insertPrepared(preparedPostsEntry) diff --git a/internal/timeline/preparedposts.go b/internal/timeline/preparedposts.go @@ -1,3 +1,21 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + package timeline import ( @@ -12,8 +30,11 @@ type preparedPosts struct { } type preparedPostsEntry struct { - statusID string - prepared *apimodel.Status + statusID string + boostOfID string + accountID string + boostOfAccountID string + prepared *apimodel.Status } func (p *preparedPosts) insertPrepared(i *preparedPostsEntry) error { diff --git a/internal/timeline/remove.go b/internal/timeline/remove.go @@ -1,3 +1,21 @@ +/* + 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 <http://www.gnu.org/licenses/>. +*/ + package timeline import ( @@ -58,3 +76,55 @@ func (t *timeline) Remove(statusID string) (int, error) { l.Debugf("removed %d entries", removed) return removed, nil } + +func (t *timeline) RemoveAllBy(accountID string) (int, error) { + l := t.log.WithFields(logrus.Fields{ + "func": "RemoveAllBy", + "accountTimeline": t.accountID, + "accountID": accountID, + }) + t.Lock() + defer t.Unlock() + var removed int + + // remove entr(ies) from the post index + removeIndexes := []*list.Element{} + if t.postIndex != nil && t.postIndex.data != nil { + for e := t.postIndex.data.Front(); e != nil; e = e.Next() { + entry, ok := e.Value.(*postIndexEntry) + if !ok { + return removed, errors.New("Remove: could not parse e as a postIndexEntry") + } + if entry.accountID == accountID || entry.boostOfAccountID == accountID { + l.Debug("found status in postIndex") + removeIndexes = append(removeIndexes, e) + } + } + } + for _, e := range removeIndexes { + t.postIndex.data.Remove(e) + removed = removed + 1 + } + + // remove entr(ies) from prepared posts + removePrepared := []*list.Element{} + if t.preparedPosts != nil && t.preparedPosts.data != nil { + for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() { + entry, ok := e.Value.(*preparedPostsEntry) + if !ok { + return removed, errors.New("Remove: could not parse e as a preparedPostsEntry") + } + if entry.accountID == accountID || entry.boostOfAccountID == accountID { + l.Debug("found status in preparedPosts") + removePrepared = append(removePrepared, e) + } + } + } + for _, e := range removePrepared { + t.preparedPosts.data.Remove(e) + removed = removed + 1 + } + + l.Debugf("removed %d entries", removed) + return removed, nil +} diff --git a/internal/timeline/timeline.go b/internal/timeline/timeline.go @@ -65,7 +65,7 @@ type Timeline interface { // // The returned bool indicates whether or not the status was actually inserted into the timeline. This will be false // if the status is a boost and the original post or another boost of it already exists < boostReinsertionDepth back in the timeline. - IndexOne(statusCreatedAt time.Time, statusID string, boostOfID string) (bool, error) + IndexOne(statusCreatedAt time.Time, statusID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) // OldestIndexedPostID returns the id of the rearmost (ie., the oldest) indexed post, or an error if something goes wrong. // If nothing goes wrong but there's no oldest post, an empty string will be returned so make sure to check for this. @@ -85,7 +85,7 @@ type Timeline interface { // // The returned bool indicates whether or not the status was actually inserted into the timeline. This will be false // if the status is a boost and the original post or another boost of it already exists < boostReinsertionDepth back in the timeline. - IndexAndPrepareOne(statusCreatedAt time.Time, statusID string) (bool, error) + IndexAndPrepareOne(statusCreatedAt time.Time, statusID string, boostOfID string, accountID string, boostOfAccountID string) (bool, error) // OldestPreparedPostID returns the id of the rearmost (ie., the oldest) prepared post, or an error if something goes wrong. // If nothing goes wrong but there's no oldest post, an empty string will be returned so make sure to check for this. OldestPreparedPostID() (string, error) @@ -109,6 +109,10 @@ type Timeline interface { // // The returned int indicates the amount of entries that were removed. Remove(statusID string) (int, error) + // RemoveAllBy removes all statuses by the given accountID, from both the index and prepared posts. + // + // The returned int indicates the amount of entries that were removed. + RemoveAllBy(accountID string) (int, error) } // timeline fulfils the Timeline interface diff --git a/internal/typeutils/asinterfaces.go b/internal/typeutils/asinterfaces.go @@ -111,6 +111,15 @@ type Likeable interface { withObject } +// Blockable represents the minimum interface for an activitystreams 'block' activity. +type Blockable interface { + withJSONLDId + withTypeName + + withActor + withObject +} + // Announceable represents the minimum interface for an activitystreams 'announce' activity. type Announceable interface { withJSONLDId diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go @@ -426,6 +426,41 @@ func (c *converter) ASLikeToFave(likeable Likeable) (*gtsmodel.StatusFave, error }, nil } +func (c *converter) ASBlockToBlock(blockable Blockable) (*gtsmodel.Block, error) { + idProp := blockable.GetJSONLDId() + if idProp == nil || !idProp.IsIRI() { + return nil, errors.New("ASBlockToBlock: no id property set on block, or was not an iri") + } + uri := idProp.GetIRI().String() + + origin, err := extractActor(blockable) + if err != nil { + return nil, errors.New("ASBlockToBlock: error extracting actor property from block") + } + originAccount := &gtsmodel.Account{} + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: origin.String()}}, originAccount); err != nil { + return nil, fmt.Errorf("ASBlockToBlock: error extracting account with uri %s from the database: %s", origin.String(), err) + } + + target, err := extractObject(blockable) + if err != nil { + return nil, errors.New("ASBlockToBlock: error extracting object property from block") + } + + targetAccount := &gtsmodel.Account{} + if err := c.db.GetWhere([]db.Where{{Key: "uri", Value: target.String(), CaseInsensitive: true}}, targetAccount); err != nil { + return nil, fmt.Errorf("ASBlockToBlock: error extracting account with uri %s from the database: %s", target.String(), err) + } + + return &gtsmodel.Block{ + AccountID: originAccount.ID, + Account: originAccount, + TargetAccountID: targetAccount.ID, + TargetAccount: targetAccount, + URI: uri, + }, nil +} + func (c *converter) ASAnnounceToStatus(announceable Announceable) (*gtsmodel.Status, bool, error) { status := &gtsmodel.Status{} isNew := true diff --git a/internal/typeutils/converter.go b/internal/typeutils/converter.go @@ -48,6 +48,10 @@ type TypeConverter interface { // 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) + // AccountToMastoBlocked takes a db model account as a param, and returns a mastotype account, or an error if + // something goes wrong. The returned account will be a bare minimum representation of the account. This function should be used + // when someone wants to view an account they've blocked. + AccountToMastoBlocked(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. @@ -104,6 +108,8 @@ type TypeConverter interface { 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) + // ASBlockToBlock converts a remote activity streams 'block' representation into a gts model block. + ASBlockToBlock(blockable Blockable) (*gtsmodel.Block, error) // ASAnnounceToStatus converts an activitystreams 'announce' into a status. // // The returned bool indicates whether this status is new (true) or not new (false). @@ -124,6 +130,11 @@ type TypeConverter interface { // AccountToAS converts a gts model account into an activity streams person, suitable for federation AccountToAS(a *gtsmodel.Account) (vocab.ActivityStreamsPerson, error) + // AccountToASMinimal converts a gts model account into an activity streams person, suitable for federation. + // + // The returned account will just have the Type, Username, PublicKey, and ID properties set. This is + // suitable for serving to requesters to whom we want to give as little information as possible because + // we don't trust them (yet). AccountToASMinimal(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) @@ -137,6 +148,8 @@ type TypeConverter interface { FaveToAS(f *gtsmodel.StatusFave) (vocab.ActivityStreamsLike, error) // BoostToAS converts a gts model boost into an activityStreams ANNOUNCE, suitable for federation BoostToAS(boostWrapperStatus *gtsmodel.Status, boostingAccount *gtsmodel.Account, boostedAccount *gtsmodel.Account) (vocab.ActivityStreamsAnnounce, error) + // BlockToAS converts a gts model block into an activityStreams BLOCK, suitable for federation. + BlockToAS(block *gtsmodel.Block) (vocab.ActivityStreamsBlock, error) /* INTERNAL (gts) MODEL TO INTERNAL MODEL diff --git a/internal/typeutils/internal.go b/internal/typeutils/internal.go @@ -67,6 +67,7 @@ func (c *converter) StatusToBoost(s *gtsmodel.Status, boostingAccount *gtsmodel. Language: s.Language, Text: s.Text, BoostOfID: s.ID, + BoostOfAccountID: s.AccountID, Visibility: s.Visibility, VisibilityAdvanced: s.VisibilityAdvanced, diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go @@ -780,3 +780,73 @@ func (c *converter) BoostToAS(boostWrapperStatus *gtsmodel.Status, boostingAccou return announce, nil } + +/* + we want to end up with something like this: + + { + "@context": "https://www.w3.org/ns/activitystreams", + "actor": "https://example.org/users/some_user", + "id":"https://example.org/users/some_user/blocks/SOME_ULID_OF_A_BLOCK", + "object":"https://some_other.instance/users/some_other_user", + "type":"Block" + } +*/ +func (c *converter) BlockToAS(b *gtsmodel.Block) (vocab.ActivityStreamsBlock, error) { + if b.Account == nil { + a := &gtsmodel.Account{} + if err := c.db.GetByID(b.AccountID, a); err != nil { + return nil, fmt.Errorf("BlockToAS: error getting block account from database: %s", err) + } + b.Account = a + } + + if b.TargetAccount == nil { + a := &gtsmodel.Account{} + if err := c.db.GetByID(b.TargetAccountID, a); err != nil { + return nil, fmt.Errorf("BlockToAS: error getting block target account from database: %s", err) + } + b.TargetAccount = a + } + + // create the block + block := streams.NewActivityStreamsBlock() + + // set the actor property to the block-ing account's URI + actorProp := streams.NewActivityStreamsActorProperty() + actorIRI, err := url.Parse(b.Account.URI) + if err != nil { + return nil, fmt.Errorf("BlockToAS: error parsing uri %s: %s", b.Account.URI, err) + } + actorProp.AppendIRI(actorIRI) + block.SetActivityStreamsActor(actorProp) + + // set the ID property to the blocks's URI + idProp := streams.NewJSONLDIdProperty() + idIRI, err := url.Parse(b.URI) + if err != nil { + return nil, fmt.Errorf("BlockToAS: error parsing uri %s: %s", b.URI, err) + } + idProp.Set(idIRI) + block.SetJSONLDId(idProp) + + // set the object property to the target account's URI + objectProp := streams.NewActivityStreamsObjectProperty() + targetIRI, err := url.Parse(b.TargetAccount.URI) + if err != nil { + return nil, fmt.Errorf("BlockToAS: error parsing uri %s: %s", b.TargetAccount.URI, err) + } + objectProp.AppendIRI(targetIRI) + block.SetActivityStreamsObject(objectProp) + + // set the TO property to the target account's IRI + toProp := streams.NewActivityStreamsToProperty() + toIRI, err := url.Parse(b.TargetAccount.URI) + if err != nil { + return nil, fmt.Errorf("BlockToAS: error parsing uri %s: %s", b.TargetAccount.URI, err) + } + toProp.AppendIRI(toIRI) + block.SetActivityStreamsTo(toProp) + + return block, nil +} diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go @@ -150,6 +150,11 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, e acct = a.Username } + var suspended bool + if !a.SuspendedAt.IsZero() { + suspended = true + } + return &model.Account{ ID: a.ID, Username: a.Username, @@ -170,6 +175,34 @@ func (c *converter) AccountToMastoPublic(a *gtsmodel.Account) (*model.Account, e LastStatusAt: lastStatusAt, Emojis: emojis, // TODO: implement this Fields: fields, + Suspended: suspended, + }, nil +} + +func (c *converter) AccountToMastoBlocked(a *gtsmodel.Account) (*model.Account, error) { + var acct string + if a.Domain != "" { + // this is a remote user + acct = fmt.Sprintf("%s@%s", a.Username, a.Domain) + } else { + // this is a local user + acct = a.Username + } + + var suspended bool + if !a.SuspendedAt.IsZero() { + suspended = true + } + + return &model.Account{ + ID: a.ID, + Username: a.Username, + Acct: acct, + DisplayName: a.DisplayName, + Bot: a.Bot, + CreatedAt: a.CreatedAt.Format(time.RFC3339), + URL: a.URL, + Suspended: suspended, }, nil } diff --git a/internal/util/regexes.go b/internal/util/regexes.go @@ -104,4 +104,9 @@ var ( // from eg /users/example_username/statuses/01F7XT5JZW1WMVSW1KADS8PVDH // The regex can be played with here: https://regex101.com/r/G9zuxQ/1 statusesPathRegex = regexp.MustCompile(statusesPathRegexString) + + blockPathRegexString = fmt.Sprintf(`^/?%s/(%s)/%s/(%s)$`, UsersPath, usernameRegexString, BlocksPath, ulidRegexString) + // blockPathRegex parses a path that validates and captures the username part and the ulid part + // from eg /users/example_username/blocks/01F7XT5JZW1WMVSW1KADS8PVDH + blockPathRegex = regexp.MustCompile(blockPathRegexString) ) diff --git a/internal/util/uri.go b/internal/util/uri.go @@ -50,6 +50,8 @@ const ( FollowPath = "follow" // UpdatePath is used to generate the URI for an account update UpdatePath = "updates" + // BlocksPath is used to generate the URI for a block + BlocksPath = "blocks" ) // APContextKey is a type used specifically for settings values on contexts within go-fed AP request chains @@ -124,6 +126,12 @@ func GenerateURIForUpdate(username string, protocol string, host string, thisUpd return fmt.Sprintf("%s://%s/%s/%s#%s/%s", protocol, host, UsersPath, username, UpdatePath, thisUpdateID) } +// GenerateURIForBlock returns the AP URI for a new block activity -- something like: +// https://example.org/users/whatever_user/blocks/01F7XTH1QGBAPMGF49WJZ91XGC +func GenerateURIForBlock(username string, protocol string, host string, thisBlockID string) string { + return fmt.Sprintf("%s://%s/%s/%s/%s/%s", protocol, host, UsersPath, username, BlocksPath, thisBlockID) +} + // GenerateURIsForAccount throws together a bunch of URIs for the given username, with the given protocol and host. func GenerateURIsForAccount(username string, protocol string, host string) *UserURIs { // The below URLs are used for serving web requests @@ -214,6 +222,11 @@ func IsPublicKeyPath(id *url.URL) bool { return userPublicKeyPathRegex.MatchString(id.Path) } +// IsBlockPath returns true if the given URL path corresponds to eg /users/example_username/blocks/SOME_ULID_OF_A_BLOCK +func IsBlockPath(id *url.URL) bool { + return blockPathRegex.MatchString(id.Path) +} + // ParseStatusesPath returns the username and ulid from a path such as /users/example_username/statuses/SOME_ULID_OF_A_STATUS func ParseStatusesPath(id *url.URL) (username string, ulid string, err error) { matches := statusesPathRegex.FindStringSubmatch(id.Path) @@ -292,3 +305,15 @@ func ParseLikedPath(id *url.URL) (username string, ulid string, err error) { ulid = matches[2] return } + +// ParseBlockPath returns the username and ulid from a path such as /users/example_username/blocks/SOME_ULID_OF_A_BLOCK +func ParseBlockPath(id *url.URL) (username string, ulid string, err error) { + matches := blockPathRegex.FindStringSubmatch(id.Path) + if len(matches) != 3 { + err = fmt.Errorf("expected 3 matches but matches length was %d", len(matches)) + return + } + username = matches[1] + ulid = matches[2] + return +}