diff options
Diffstat (limited to 'internal/federation/federatingdb/interactionrequest.go')
| -rw-r--r-- | internal/federation/federatingdb/interactionrequest.go | 572 |
1 files changed, 572 insertions, 0 deletions
diff --git a/internal/federation/federatingdb/interactionrequest.go b/internal/federation/federatingdb/interactionrequest.go new file mode 100644 index 000000000..0026337d0 --- /dev/null +++ b/internal/federation/federatingdb/interactionrequest.go @@ -0,0 +1,572 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// 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 federatingdb + +import ( + "context" + "errors" + "net/http" + + "code.superseriousbusiness.org/activity/streams/vocab" + "code.superseriousbusiness.org/gotosocial/internal/ap" + "code.superseriousbusiness.org/gotosocial/internal/db" + "code.superseriousbusiness.org/gotosocial/internal/gtserror" + "code.superseriousbusiness.org/gotosocial/internal/gtsmodel" + "code.superseriousbusiness.org/gotosocial/internal/id" + "code.superseriousbusiness.org/gotosocial/internal/log" + "code.superseriousbusiness.org/gotosocial/internal/messages" + "code.superseriousbusiness.org/gotosocial/internal/util" +) + +/* + The code in this file handles the three types of "polite" + interaction requests currently recognized by GoToSocial: + LikeRequest, ReplyRequest, and AnnounceRequest. + + A request looks a bit like this, note the requested + interaction itself is nested in the "instrument" property: + + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://gotosocial.org/ns" + ], + "type": "LikeRequest", + "id": "https://example.com/users/bob/interaction_requests/likes/12345", + "actor": "https://example.com/users/bob", + "object": "https://example.com/users/alice/statuses/1", + "to": "https://example.com/users/alice", + "instrument": { + "type": "Like", + "id": "https://example.com/users/bob/likes/12345", + "object": "https://example.com/users/alice/statuses/1", + "attributedTo": "https://example.com/users/bob", + "to": [ + "https://www.w3.org/ns/activitystreams#Public", + "https://example.com/users/alice" + ] + } + } + + Because each of the interaction types are a bit different, + they're unfortunately also parsed and stored differently: + LikeRequests have the Like checked first, here, against + the interaction policy of the target status, whereas + AnnounceRequests and ReplyRequests have the interaction + checked against the interaction policy of the target + status asynchronously, in the FromFediAPI processor. + + It may be possible to dick about with the logic a bit and + shuffle the checks all here, or all in the processor, but + that's a job for future refactoring by future tobi/kimbe. +*/ + +// partialInteractionRequest represents a +// partially-parsed interaction request +// returned from the util function parseInteractionReq. +type partialInteractionRequest struct { + intRequestURI string + requesting *gtsmodel.Account + receiving *gtsmodel.Account + object *gtsmodel.Status + instrument vocab.Type +} + +// parseIntReq does some first-pass parsing +// of the given InteractionRequestable (LikeRequest, +// ReplyRequest, AnnounceRequest), checking stuff like: +// +// - interaction request has a single object +// - interaction request object is a status +// - object status belongs to receiving account +// - interaction request has a single instrument +// +// It returns a partialInteractionRequest struct, +// or an error if something goes wrong. +func (f *DB) parseInteractionRequest(ctx context.Context, intRequest ap.InteractionRequestable) (*partialInteractionRequest, error) { + + // Get and stringify the ID/URI of interaction request once, + // and mark this particular activity as handled in ID cache. + intRequestURI := ap.GetJSONLDId(intRequest).String() + f.activityIDs.Set(intRequestURI, struct{}{}) + + // Extract relevant values from passed ctx. + activityContext := getActivityContext(ctx) + if activityContext.internal { + // Already processed. + return nil, nil + } + + requesting := activityContext.requestingAcct + receiving := activityContext.receivingAcct + + if requesting.IsMoving() { + // A Moving account + // can't do this. + return nil, nil + } + + if receiving.IsMoving() { + // Moving accounts can't + // do anything with interaction + // requests, so ignore it. + return nil, nil + } + + // Make sure we have a single + // object of the interaction request. + objectIRIs := ap.GetObjectIRIs(intRequest) + if l := len(objectIRIs); l != 1 { + return nil, gtserror.NewfWithCode( + http.StatusBadRequest, + "invalid object len %d, wanted 1", l, + ) + } + + // Extract the status URI str. + statusIRI := objectIRIs[0] + statusIRIStr := statusIRI.String() + + // Fetch status by given URI from the database. + status, err := f.state.DB.GetStatusByURI(ctx, statusIRIStr) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return nil, gtserror.Newf("db error getting object status %s: %w", statusIRIStr, err) + } + + // Ensure received by correct account. + if status.AccountID != receiving.ID { + return nil, gtserror.NewfWithCode( + http.StatusForbidden, + "receiver %s is not owner of interaction-requested status", + receiving.URI, + ) + } + + // Ensure we have the expected one instrument. + instruments := ap.ExtractInstruments(intRequest) + if l := len(instruments); l != 1 { + return nil, gtserror.NewfWithCode( + http.StatusBadRequest, + "invalid instrument len %d, wanted 1", l, + ) + } + + // Instrument should be a + // type and not just an IRI. + instrument := instruments[0].GetType() + if instrument == nil { + return nil, gtserror.NewWithCode( + http.StatusBadRequest, + "instrument was not vocab.Type", + ) + } + + // Check the instrument is an approveable type. + approvable, ok := instrument.(ap.WithApprovedBy) + if !ok { + return nil, gtserror.NewWithCode( + http.StatusBadRequest, + "instrument was not Approvable", + ) + } + + // Ensure that `approvedBy` isn't already set. + if u := ap.GetApprovedBy(approvable); u != nil { + return nil, gtserror.NewfWithCode( + http.StatusBadRequest, + "instrument claims to already be approvedBy %s", + u.String(), + ) + } + + return &partialInteractionRequest{ + intRequestURI: intRequestURI, + requesting: requesting, + receiving: receiving, + object: status, + instrument: instrument, + }, nil +} + +func (f *DB) LikeRequest(ctx context.Context, likeReq vocab.GoToSocialLikeRequest) error { + log.DebugKV(ctx, "LikeRequest", serialize{likeReq}) + + // Parse out base level interaction request information. + partial, err := f.parseInteractionRequest(ctx, likeReq) + if err != nil { + return err + } + + // Ensure the instrument vocab.Type is Likeable. + likeable, ok := ap.ToLikeable(partial.instrument) + if !ok { + return gtserror.NewWithCode( + http.StatusBadRequest, + "could not parse instrument to Likeable", + ) + } + + // Convert received AS like type to internal fave model. + fave, err := f.converter.ASLikeToFave(ctx, likeable) + if err != nil { + err := gtserror.Newf("error converting from AS type: %w", err) + return gtserror.WrapWithCode(http.StatusBadRequest, err) + } + + // Ensure fave enacted by correct account. + if fave.AccountID != partial.requesting.ID { + return gtserror.NewfWithCode( + http.StatusForbidden, + "requester %s is not expected actor %s", + partial.requesting.URI, fave.Account.URI, + ) + } + + // Ensure fave received by correct account. + if fave.TargetAccountID != partial.receiving.ID { + return gtserror.NewfWithCode( + http.StatusForbidden, + "receiver %s is not expected %s", + partial.receiving.URI, fave.TargetAccount.URI, + ) + } + + // Ensure this is a valid Like target for requester. + policyResult, err := f.intFilter.StatusLikeable(ctx, + partial.requesting, + fave.Status, + ) + if err != nil { + return gtserror.Newf( + "error seeing if status %s is likeable: %w", + fave.Status.URI, err, + ) + } else if policyResult.Forbidden() { + return gtserror.NewWithCode( + http.StatusForbidden, + "requester does not have permission to Like status", + ) + } + + // Policy result is either automatic or manual + // approval, so store the interaction request. + intReq := >smodel.InteractionRequest{ + ID: id.NewULID(), + TargetStatusID: fave.StatusID, + TargetStatus: fave.Status, + TargetAccountID: fave.TargetAccountID, + TargetAccount: fave.TargetAccount, + InteractingAccountID: fave.AccountID, + InteractingAccount: fave.Account, + InteractionRequestURI: partial.intRequestURI, + InteractionURI: fave.URI, + InteractionType: gtsmodel.InteractionLike, + Polite: util.Ptr(true), + Like: fave, + } + switch err := f.state.DB.PutInteractionRequest(ctx, intReq); { + case err == nil: + // No problem. + + case errors.Is(err, db.ErrAlreadyExists): + // Already processed this, race condition? Just warn + return. + log.Warnf(ctx, "received duplicate interaction request: %s", partial.intRequestURI) + return nil + + default: + // Proper DB error. + return gtserror.Newf( + "db error storing interaction request %s", + partial.intRequestURI, + ) + } + + // Int req is now stored. + // + // Set some fields on the + // pending fave and store it. + fave.ID = id.NewULID() + fave.PendingApproval = util.Ptr(true) + fave.PreApproved = policyResult.AutomaticApproval() + if err := f.state.DB.PutStatusFave(ctx, fave); err != nil { + if errors.Is(err, db.ErrAlreadyExists) { + // The fave already exists in the + // database, which means we've already + // handled side effects. We can just + // return nil here and be done with it. + return nil + } + + return gtserror.Newf("error inserting %s into db: %w", fave.URI, err) + } + + // Further processing will be carried out + // asynchronously, and our caller will return 202 Accepted. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APActivityType: ap.ActivityCreate, + APObjectType: ap.ActivityLikeRequest, + GTSModel: intReq, + Receiving: partial.receiving, + Requesting: partial.requesting, + }) + + return nil +} + +func (f *DB) ReplyRequest(ctx context.Context, replyReq vocab.GoToSocialReplyRequest) error { + log.DebugKV(ctx, "ReplyRequest", serialize{replyReq}) + + // Parse out base level interaction request information. + partial, err := f.parseInteractionRequest(ctx, replyReq) + if err != nil { + return err + } + + // Ensure the instrument vocab.Type is Statusable. + statusable, ok := ap.ToStatusable(partial.instrument) + if !ok { + return gtserror.NewWithCode( + http.StatusBadRequest, + "could not parse instrument to Statusable", + ) + } + + // Check for spam / relevance. + ok, err = f.statusableOK( + ctx, + partial.receiving, + partial.requesting, + statusable, + ) + if err != nil { + // Error already + // wrapped. + return err + } + + if !ok { + // Not relevant / spam. + // Already logged. + return nil + } + + // Statusable must reply to something. + inReplyToURIs := ap.GetInReplyTo(statusable) + if l := len(inReplyToURIs); l != 1 { + return gtserror.NewfWithCode( + http.StatusBadRequest, + "expected inReplyTo length 1, got %d", l, + ) + } + inReplyToURI := inReplyToURIs[0] + inReplyToURIStr := inReplyToURI.String() + + // Make sure we have the status this interaction reply encompasses. + inReplyTo, err := f.state.DB.GetStatusByURI(ctx, inReplyToURIStr) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return gtserror.Newf("db error getting inReplyTo status %s: %w", inReplyToURIStr, err) + } + + // Check status exists. + if inReplyTo == nil { + log.Warnf(ctx, "received ReplyRequest for non-existent status: %s", inReplyToURIStr) + return nil + } + + // Make sure the parent status is owned by receiver. + if inReplyTo.AccountURI != partial.receiving.URI { + return gtserror.NewfWithCode( + http.StatusBadRequest, + "inReplyTo status %s not owned by receiving account %s", + inReplyToURIStr, partial.receiving.URI, + ) + } + + // Extract the attributed to (i.e. author) URI of status. + attributedToURI, err := ap.ExtractAttributedToURI(statusable) + if err != nil { + err := gtserror.Newf("invalid status attributedTo value: %w", err) + return gtserror.WrapWithCode(http.StatusBadRequest, err) + } + + // Ensure status author is account of requester. + attributedToURIStr := attributedToURI.String() + if attributedToURIStr != partial.requesting.URI { + return gtserror.NewfWithCode( + http.StatusBadRequest, + "status attributedTo %s not requesting account %s", + inReplyToURIStr, partial.requesting.URI, + ) + } + + // Create a pending interaction request in the database. + // This request will be handled further by the processor. + intReq := >smodel.InteractionRequest{ + ID: id.NewULID(), + TargetStatusID: inReplyTo.ID, + TargetStatus: inReplyTo, + TargetAccountID: inReplyTo.AccountID, + TargetAccount: inReplyTo.Account, + InteractingAccountID: partial.requesting.ID, + InteractingAccount: partial.requesting, + InteractionRequestURI: partial.intRequestURI, + InteractionURI: ap.GetJSONLDId(statusable).String(), + InteractionType: gtsmodel.InteractionReply, + Polite: util.Ptr(true), + Reply: nil, // Not settable yet. + } + switch err := f.state.DB.PutInteractionRequest(ctx, intReq); { + case err == nil: + // No problem. + + case errors.Is(err, db.ErrAlreadyExists): + // Already processed this, race condition? Just warn + return. + log.Warnf(ctx, "received duplicate interaction request: %s", partial.intRequestURI) + return nil + + default: + // Proper DB error. + return gtserror.Newf( + "db error storing interaction request %s", + partial.intRequestURI, + ) + } + + // Further processing will be carried out + // asynchronously, return 202 Accepted. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APActivityType: ap.ActivityCreate, + APObjectType: ap.ActivityReplyRequest, + GTSModel: intReq, + APObject: statusable, + Receiving: partial.receiving, + Requesting: partial.requesting, + }) + + return nil +} + +func (f *DB) AnnounceRequest(ctx context.Context, announceReq vocab.GoToSocialAnnounceRequest) error { + log.DebugKV(ctx, "AnnounceRequest", serialize{announceReq}) + + // Parse out base level interaction request information. + partial, err := f.parseInteractionRequest(ctx, announceReq) + if err != nil { + return err + } + + // Ensure the instrument vocab.Type is Announceable. + announceable, ok := ap.ToAnnounceable(partial.instrument) + if !ok { + return gtserror.NewWithCode( + http.StatusBadRequest, + "could not parse instrument to Announceable", + ) + } + + // Convert received AS Announce type to internal boost wrapper model. + boost, new, err := f.converter.ASAnnounceToStatus(ctx, announceable) + if err != nil { + err := gtserror.Newf("error converting from AS type: %w", err) + return gtserror.WrapWithCode(http.StatusBadRequest, err) + } + + if !new { + // We already have this announce, just return. + log.Warnf(ctx, "received AnnounceRequest for existing announce: %s", boost.URI) + return nil + } + + // Fetch origin status that this boost is targetting from database. + targetStatus, err := f.state.DB.GetStatusByURI(ctx, boost.BoostOfURI) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return gtserror.Newf( + "db error getting announce object %s: %w", + boost.BoostOfURI, err, + ) + } + + if targetStatus == nil { + // Status doesn't seem to exist, just drop this AnnounceRequest. + log.Warnf(ctx, "received AnnounceRequest for non-existent status %s", boost.BoostOfURI) + return nil + } + + // Ensure target status is owned by receiving account. + if targetStatus.AccountID != partial.receiving.ID { + return gtserror.NewfWithCode( + http.StatusBadRequest, + "announce object %s not owned by receiving account %s", + boost.BoostOfURI, partial.receiving.URI, + ) + } + + // Ensure announce enacted by correct account. + if boost.AccountID != partial.requesting.ID { + return gtserror.NewfWithCode( + http.StatusForbidden, + "requester %s is not expected actor %s", + partial.requesting.URI, boost.Account.URI, + ) + } + + // Create a pending interaction request in the database. + // This request will be handled further by the processor. + intReq := >smodel.InteractionRequest{ + ID: id.NewULID(), + TargetStatusID: targetStatus.ID, + TargetStatus: targetStatus, + TargetAccountID: targetStatus.AccountID, + TargetAccount: targetStatus.Account, + InteractingAccountID: boost.AccountID, + InteractingAccount: boost.Account, + InteractionRequestURI: partial.intRequestURI, + InteractionURI: boost.URI, + InteractionType: gtsmodel.InteractionAnnounce, + Polite: util.Ptr(true), + Announce: boost, + } + switch err := f.state.DB.PutInteractionRequest(ctx, intReq); { + case err == nil: + // No problem. + + case errors.Is(err, db.ErrAlreadyExists): + // Already processed this, race condition? Just warn + return. + log.Warnf(ctx, "received duplicate interaction request: %s", partial.intRequestURI) + return nil + + default: + // Proper DB error. + return gtserror.Newf( + "db error storing interaction request %s", + partial.intRequestURI, + ) + } + + // Further processing will be carried out + // asynchronously, return 202 Accepted. + f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{ + APActivityType: ap.ActivityCreate, + APObjectType: ap.ActivityAnnounceRequest, + GTSModel: intReq, + Receiving: partial.receiving, + Requesting: partial.requesting, + }) + + return nil +} |
