From c6e00afc7c23df994b70eee89d2d392718e6a321 Mon Sep 17 00:00:00 2001 From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com> Date: Wed, 4 Oct 2023 13:09:42 +0100 Subject: [PATCH] [feature] tentatively start adding polls support (#2249) --- .golangci.yml | 1 - internal/ap/collections.go | 12 +- internal/ap/extract.go | 47 +++- internal/ap/interfaces.go | 16 +- internal/ap/normalize.go | 74 +++-- internal/ap/util.go | 43 +++ internal/api/client/admin/domainpermission.go | 1 - .../api/client/statuses/statuscreate_test.go | 2 +- internal/federation/dereferencing/status.go | 17 +- internal/federation/federatingdb/accept.go | 69 ++--- internal/federation/federatingdb/create.go | 36 +-- internal/federation/federatingdb/db.go | 1 + internal/federation/federatingdb/question.go | 32 +++ internal/federation/federatingdb/undo.go | 31 ++- internal/federation/federatingdb/update.go | 57 +++- internal/federation/federatingprotocol.go | 3 + internal/processing/status/create.go | 263 +++++++++--------- internal/processing/status/create_test.go | 2 +- internal/processing/stream/stream.go | 2 +- internal/processing/workers/federate.go | 64 ++++- internal/processing/workers/fromclientapi.go | 21 +- internal/processing/workers/fromfediapi.go | 36 ++- internal/processing/workers/wipestatus.go | 17 +- internal/text/plain.go | 15 +- internal/timeline/get.go | 5 +- internal/timeline/index.go | 5 +- internal/timeline/indexeditems.go | 2 +- internal/timeline/manager.go | 3 +- internal/timeline/prepare.go | 4 +- internal/timeline/prune.go | 2 +- internal/timeline/remove.go | 4 +- internal/timeline/unprepare.go | 2 +- internal/typeutils/astointernal.go | 50 ++-- internal/typeutils/internaltoas.go | 24 +- internal/typeutils/wrap.go | 83 +++++- internal/typeutils/wrap_test.go | 4 +- 36 files changed, 657 insertions(+), 393 deletions(-) create mode 100644 internal/ap/util.go create mode 100644 internal/federation/federatingdb/question.go diff --git a/.golangci.yml b/.golangci.yml index 786cf3a40..332bf9d6b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -14,7 +14,6 @@ run: linters: # enable some extra linters, see here for the list: https://golangci-lint.run/usage/linters/ enable: - - forcetypeassert - goconst - gocritic - gofmt diff --git a/internal/ap/collections.go b/internal/ap/collections.go index 471dae0a1..e86d989ff 100644 --- a/internal/ap/collections.go +++ b/internal/ap/collections.go @@ -32,10 +32,10 @@ import ( func ToCollectionPageIterator(t vocab.Type) (CollectionPageIterator, error) { switch name := t.GetTypeName(); name { case ObjectCollectionPage: - t := t.(vocab.ActivityStreamsCollectionPage) //nolint:forcetypeassert + t := t.(vocab.ActivityStreamsCollectionPage) return WrapCollectionPage(t), nil case ObjectOrderedCollectionPage: - t := t.(vocab.ActivityStreamsOrderedCollectionPage) //nolint:forcetypeassert + t := t.(vocab.ActivityStreamsOrderedCollectionPage) return WrapOrderedCollectionPage(t), nil default: return nil, fmt.Errorf("%T(%s) was not CollectionPage-like", t, name) @@ -74,7 +74,7 @@ func (iter *regularCollectionPageIterator) PrevPage() WithIRI { return iter.GetActivityStreamsPrev() } -func (iter *regularCollectionPageIterator) NextItem() IteratorItemable { +func (iter *regularCollectionPageIterator) NextItem() TypeOrIRI { if !iter.initItems() { return nil } @@ -83,7 +83,7 @@ func (iter *regularCollectionPageIterator) NextItem() IteratorItemable { return cur } -func (iter *regularCollectionPageIterator) PrevItem() IteratorItemable { +func (iter *regularCollectionPageIterator) PrevItem() TypeOrIRI { if !iter.initItems() { return nil } @@ -130,7 +130,7 @@ func (iter *orderedCollectionPageIterator) PrevPage() WithIRI { return iter.GetActivityStreamsPrev() } -func (iter *orderedCollectionPageIterator) NextItem() IteratorItemable { +func (iter *orderedCollectionPageIterator) NextItem() TypeOrIRI { if !iter.initItems() { return nil } @@ -139,7 +139,7 @@ func (iter *orderedCollectionPageIterator) NextItem() IteratorItemable { return cur } -func (iter *orderedCollectionPageIterator) PrevItem() IteratorItemable { +func (iter *orderedCollectionPageIterator) PrevItem() TypeOrIRI { if !iter.initItems() { return nil } diff --git a/internal/ap/extract.go b/internal/ap/extract.go index 4cefd22dc..41cc5dcbc 100644 --- a/internal/ap/extract.go +++ b/internal/ap/extract.go @@ -35,39 +35,56 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/util" ) -// ExtractObject will extract an object vocab.Type from given implementing interface. -func ExtractObject(with WithObject) vocab.Type { +// ExtractObjects will extract object vocab.Types from given implementing interface. +func ExtractObjects(with WithObject) []TypeOrIRI { // Extract the attached object (if any). - obj := with.GetActivityStreamsObject() - if obj == nil { + objProp := with.GetActivityStreamsObject() + if objProp == nil { return nil } - // Only support single - // objects (for now...) - if obj.Len() != 1 { + // Check for zero len. + if objProp.Len() == 0 { return nil } - // Extract object vocab.Type. - return obj.At(0).GetType() + // Accumulate all of the objects into a slice. + objs := make([]TypeOrIRI, objProp.Len()) + for i := 0; i < objProp.Len(); i++ { + objs[i] = objProp.At(i) + } + + return objs } // ExtractActivityData will extract the usable data type (e.g. Note, Question, etc) and corresponding JSON, from activity. -func ExtractActivityData(activity pub.Activity, rawJSON map[string]any) (vocab.Type, map[string]any, bool) { +func ExtractActivityData(activity pub.Activity, rawJSON map[string]any) ([]TypeOrIRI, []any, bool) { switch typeName := activity.GetTypeName(); { // Activity (has "object"). case isActivity(typeName): - objType := ExtractObject(activity) - if objType == nil { + objTypes := ExtractObjects(activity) + if len(objTypes) == 0 { return nil, nil, false } - objJSON, _ := rawJSON["object"].(map[string]any) - return objType, objJSON, true + + var objJSON []any + switch json := rawJSON["object"].(type) { + case nil: + // do nothing + case map[string]any: + // Wrap map in slice. + objJSON = []any{json} + case []any: + // Use existing slice. + objJSON = json + } + + return objTypes, objJSON, true // IntransitiveAcitivity (no "object"). case isIntransitiveActivity(typeName): - return activity, rawJSON, false + asTypeOrIRI := _TypeOrIRI{activity} // wrap activity. + return []TypeOrIRI{&asTypeOrIRI}, []any{rawJSON}, true // Unknown. default: diff --git a/internal/ap/interfaces.go b/internal/ap/interfaces.go index 4538c476f..9e606db62 100644 --- a/internal/ap/interfaces.go +++ b/internal/ap/interfaces.go @@ -247,14 +247,8 @@ type CollectionPageIterator interface { NextPage() WithIRI PrevPage() WithIRI - NextItem() IteratorItemable - PrevItem() IteratorItemable -} - -// IteratorItemable represents the minimum interface for an item in an iterator. -type IteratorItemable interface { - WithIRI - WithType + NextItem() TypeOrIRI + PrevItem() TypeOrIRI } // Flaggable represents the minimum interface for an activitystreams 'Flag' activity. @@ -267,6 +261,12 @@ type Flaggable interface { WithObject } +// TypeOrIRI represents the minimum interface for something that may be a vocab.Type OR IRI. +type TypeOrIRI interface { + WithIRI + WithType +} + // WithJSONLDId represents an activity with JSONLDIdProperty. type WithJSONLDId interface { GetJSONLDId() vocab.JSONLDIdProperty diff --git a/internal/ap/normalize.go b/internal/ap/normalize.go index 52ada2848..192a2d740 100644 --- a/internal/ap/normalize.go +++ b/internal/ap/normalize.go @@ -39,60 +39,48 @@ import ( // This function is a noop if the type passed in is anything except a Create or Update with a Statusable or Accountable as its Object. func NormalizeIncomingActivity(activity pub.Activity, rawJSON map[string]interface{}) { // From the activity extract the data vocab.Type + its "raw" JSON. - dataType, rawData, ok := ExtractActivityData(activity, rawJSON) - if !ok { + dataIfaces, rawData, ok := ExtractActivityData(activity, rawJSON) + if !ok || len(dataIfaces) != len(rawData) { + // non-equal lengths *shouldn't* happen, + // but this is just an integrity check. return } - switch dataType.GetTypeName() { - // "Pollable" types. - case ActivityQuestion: - pollable, ok := dataType.(Pollable) - if !ok { - return + // Iterate over the available data. + for i, dataIface := range dataIfaces { + // Try to get as vocab.Type, else + // skip this entry for normalization. + dataType := dataIface.GetType() + if dataType == nil { + continue } - // Normalize the Pollable specific properties. - NormalizeIncomingPollOptions(pollable, rawData) - - // Fallthrough to handle - // the rest as Statusable. - fallthrough - - // "Statusable" types. - case ObjectArticle, - ObjectDocument, - ObjectImage, - ObjectVideo, - ObjectNote, - ObjectPage, - ObjectEvent, - ObjectPlace, - ObjectProfile: - statusable, ok := dataType.(Statusable) + // Get the raw data map at index, else skip + // this entry due to impossible normalization. + rawData, ok := rawData[i].(map[string]any) if !ok { - return + continue } - // Normalize everything we can on the statusable. - NormalizeIncomingContent(statusable, rawData) - NormalizeIncomingAttachments(statusable, rawData) - NormalizeIncomingSummary(statusable, rawData) - NormalizeIncomingName(statusable, rawData) + if statusable, ok := ToStatusable(dataType); ok { + if pollable, ok := ToPollable(dataType); ok { + // Normalize the Pollable specific properties. + NormalizeIncomingPollOptions(pollable, rawData) + } - // "Accountable" types. - case ActorApplication, - ActorGroup, - ActorOrganization, - ActorPerson, - ActorService: - accountable, ok := dataType.(Accountable) - if !ok { - return + // Normalize everything we can on the statusable. + NormalizeIncomingContent(statusable, rawData) + NormalizeIncomingAttachments(statusable, rawData) + NormalizeIncomingSummary(statusable, rawData) + NormalizeIncomingName(statusable, rawData) + continue } - // Normalize everything we can on the accountable. - NormalizeIncomingSummary(accountable, rawData) + if accountable, ok := ToAccountable(dataType); ok { + // Normalize everything we can on the accountable. + NormalizeIncomingSummary(accountable, rawData) + continue + } } } diff --git a/internal/ap/util.go b/internal/ap/util.go new file mode 100644 index 000000000..c810b7985 --- /dev/null +++ b/internal/ap/util.go @@ -0,0 +1,43 @@ +// 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 . + +package ap + +import ( + "net/url" + + "github.com/superseriousbusiness/activity/streams/vocab" +) + +// _TypeOrIRI wraps a vocab.Type to implement TypeOrIRI. +type _TypeOrIRI struct { + vocab.Type +} + +func (t *_TypeOrIRI) GetType() vocab.Type { + return t.Type +} + +func (t *_TypeOrIRI) GetIRI() *url.URL { + return nil +} + +func (t *_TypeOrIRI) IsIRI() bool { + return false +} + +func (t *_TypeOrIRI) SetIRI(*url.URL) {} diff --git a/internal/api/client/admin/domainpermission.go b/internal/api/client/admin/domainpermission.go index 80aa05041..bd6b83425 100644 --- a/internal/api/client/admin/domainpermission.go +++ b/internal/api/client/admin/domainpermission.go @@ -144,7 +144,6 @@ func (m *Module) createDomainPermissions( if multiStatus.Metadata.Failure != 0 { failures := make(map[string]any, multiStatus.Metadata.Failure) for _, entry := range multiStatus.Data { - // nolint:forcetypeassert failures[entry.Resource.(string)] = entry.Message } diff --git a/internal/api/client/statuses/statuscreate_test.go b/internal/api/client/statuses/statuscreate_test.go index 6238b111a..e945d9caf 100644 --- a/internal/api/client/statuses/statuscreate_test.go +++ b/internal/api/client/statuses/statuscreate_test.go @@ -290,7 +290,7 @@ func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() { defer result.Body.Close() b, err := ioutil.ReadAll(result.Body) suite.NoError(err) - suite.Equal(`{"error":"Bad Request: status with id 3759e7ef-8ee1-4c0c-86f6-8b70b9ad3d50 not replyable because it doesn't exist"}`, string(b)) + suite.Equal(`{"error":"Bad Request: cannot reply to status that does not exist"}`, string(b)) } // Post a reply to the status of a local user that allows replies. diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index 5ec04175e..84316f3a9 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -288,8 +288,8 @@ func (d *deref) enrichStatus( return nil, nil, gtserror.Newf("error populating mentions for status %s: %w", uri, err) } - // Ensure the status' tags are populated. - if err := d.fetchStatusTags(ctx, requestUser, latestStatus); err != nil { + // Ensure the status' tags are populated, (changes are expected / okay). + if err := d.fetchStatusTags(ctx, latestStatus); err != nil { return nil, nil, gtserror.Newf("error populating tags for status %s: %w", uri, err) } @@ -298,8 +298,8 @@ func (d *deref) enrichStatus( return nil, nil, gtserror.Newf("error populating attachments for status %s: %w", uri, err) } - // Ensure the status' emoji attachments are populated, passing in existing to check for changes. - if err := d.fetchStatusEmojis(ctx, requestUser, status, latestStatus); err != nil { + // Ensure the status' emoji attachments are populated, (changes are expected / okay). + if err := d.fetchStatusEmojis(ctx, requestUser, latestStatus); err != nil { return nil, nil, gtserror.Newf("error populating emojis for status %s: %w", uri, err) } @@ -359,6 +359,8 @@ func (d *deref) fetchStatusMentions(ctx context.Context, requestUser string, exi } // Generate new ID according to status creation. + // TODO: update this to use "edited_at" when we add + // support for edited status revision history. mention.ID, err = id.NewULIDFromTime(status.CreatedAt) if err != nil { log.Errorf(ctx, "invalid created at date: %v", err) @@ -403,7 +405,7 @@ func (d *deref) fetchStatusMentions(ctx context.Context, requestUser string, exi return nil } -func (d *deref) fetchStatusTags(ctx context.Context, requestUser string, status *gtsmodel.Status) error { +func (d *deref) fetchStatusTags(ctx context.Context, status *gtsmodel.Status) error { // Allocate new slice to take the yet-to-be determined tag IDs. status.TagIDs = make([]string, len(status.Tags)) @@ -417,13 +419,14 @@ func (d *deref) fetchStatusTags(ctx context.Context, requestUser string, status continue } - // No tag with this name yet, create it. if tag == nil { + // Create new ID for tag name. tag = >smodel.Tag{ ID: id.NewULID(), Name: placeholder.Name, } + // Insert this tag with new name into the database. if err := d.state.DB.PutTag(ctx, tag); err != nil { log.Errorf(ctx, "db error putting tag %s: %v", tag.Name, err) continue @@ -516,7 +519,7 @@ func (d *deref) fetchStatusAttachments(ctx context.Context, tsport transport.Tra return nil } -func (d *deref) fetchStatusEmojis(ctx context.Context, requestUser string, existing, status *gtsmodel.Status) error { +func (d *deref) fetchStatusEmojis(ctx context.Context, requestUser string, status *gtsmodel.Status) error { // Fetch the full-fleshed-out emoji objects for our status. emojis, err := d.populateEmojis(ctx, status.Emojis, requestUser) if err != nil { diff --git a/internal/federation/federatingdb/accept.go b/internal/federation/federatingdb/accept.go index 1c514d035..38b6b9300 100644 --- a/internal/federation/federatingdb/accept.go +++ b/internal/federation/federatingdb/accept.go @@ -46,28 +46,29 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA return nil // Already processed. } - acceptObject := accept.GetActivityStreamsObject() - if acceptObject == nil { - return errors.New("ACCEPT: no object set on vocab.ActivityStreamsAccept") - } + // Iterate all provided objects in the activity. + for _, object := range ap.ExtractObjects(accept) { - for iter := acceptObject.Begin(); iter != acceptObject.End(); iter = iter.Next() { - // check if the object is an IRI - if iter.IsIRI() { - // we have just the URI of whatever is being accepted, so we need to find out what it is - acceptedObjectIRI := iter.GetIRI() - if uris.IsFollowPath(acceptedObjectIRI) { - // ACCEPT FOLLOW - followReq, err := f.state.DB.GetFollowRequestByURI(ctx, acceptedObjectIRI.String()) + // Check and handle any vocab.Type objects. + if objType := object.GetType(); objType != nil { + switch objType.GetTypeName() { //nolint:gocritic + + case ap.ActivityFollow: + // Cast the vocab.Type object to known AS type. + asFollow := objType.(vocab.ActivityStreamsFollow) + + // convert the follow to something we can understand + gtsFollow, err := f.converter.ASFollowToFollow(ctx, asFollow) if err != nil { - return fmt.Errorf("ACCEPT: couldn't get follow request with id %s from the database: %s", acceptedObjectIRI.String(), err) + return fmt.Errorf("ACCEPT: error converting asfollow to gtsfollow: %s", err) } // make sure the addressee of the original follow is the same as whatever inbox this landed in - if followReq.AccountID != receivingAccount.ID { + if gtsFollow.AccountID != receivingAccount.ID { return errors.New("ACCEPT: follow object account and inbox account were not the same") } - follow, err := f.state.DB.AcceptFollowRequest(ctx, followReq.AccountID, followReq.TargetAccountID) + + follow, err := f.state.DB.AcceptFollowRequest(ctx, gtsFollow.AccountID, gtsFollow.TargetAccountID) if err != nil { return err } @@ -78,31 +79,36 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA GTSModel: follow, ReceivingAccount: receivingAccount, }) - - return nil } - } - // check if iter is an AP object / type - if iter.GetType() == nil { continue } - if iter.GetType().GetTypeName() == ap.ActivityFollow { + + // Check and handle any + // IRI type objects. + if object.IsIRI() { + + // Extract IRI from object. + iri := object.GetIRI() + if !uris.IsFollowPath(iri) { + continue + } + + // Serialize IRI. + iriStr := iri.String() + // ACCEPT FOLLOW - asFollow, ok := iter.GetType().(vocab.ActivityStreamsFollow) - if !ok { - return errors.New("ACCEPT: couldn't parse follow into vocab.ActivityStreamsFollow") - } - // convert the follow to something we can understand - gtsFollow, err := f.converter.ASFollowToFollow(ctx, asFollow) + followReq, err := f.state.DB.GetFollowRequestByURI(ctx, iriStr) if err != nil { - return fmt.Errorf("ACCEPT: error converting asfollow to gtsfollow: %s", err) + return fmt.Errorf("ACCEPT: couldn't get follow request with id %s from the database: %s", iriStr, err) } + // make sure the addressee of the original follow is the same as whatever inbox this landed in - if gtsFollow.AccountID != receivingAccount.ID { + if followReq.AccountID != receivingAccount.ID { return errors.New("ACCEPT: follow object account and inbox account were not the same") } - follow, err := f.state.DB.AcceptFollowRequest(ctx, gtsFollow.AccountID, gtsFollow.TargetAccountID) + + follow, err := f.state.DB.AcceptFollowRequest(ctx, followReq.AccountID, followReq.TargetAccountID) if err != nil { return err } @@ -114,8 +120,9 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA ReceivingAccount: receivingAccount, }) - return nil + continue } + } return nil diff --git a/internal/federation/federatingdb/create.go b/internal/federation/federatingdb/create.go index 12e324166..6cb230589 100644 --- a/internal/federation/federatingdb/create.go +++ b/internal/federation/federatingdb/create.go @@ -81,6 +81,7 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error { // FLAG / REPORT SOMETHING return f.activityFlag(ctx, asType, receivingAccount, requestingAccount) } + return nil } @@ -111,6 +112,7 @@ func (f *federatingDB) activityBlock(ctx context.Context, asType vocab.Type, rec GTSModel: block, ReceivingAccount: receiving, }) + return nil } @@ -132,37 +134,19 @@ func (f *federatingDB) activityCreate( return gtserror.Newf("could not convert asType %T to ActivityStreamsCreate", asType) } - // Create must have an Object. - objectProp := create.GetActivityStreamsObject() - if objectProp == nil { - return gtserror.New("create had no Object") - } - - // Iterate through the Object property and process FIRST provided statusable. - // todo: https://github.com/superseriousbusiness/gotosocial/issues/1905 - for iter := objectProp.Begin(); iter != objectProp.End(); iter = iter.Next() { - object := iter.GetType() - if object == nil { - // Can't do Create with Object that's just a URI. - // Warn log this because it's an AP error. - log.Warn(ctx, "object entry was not a type: %[1]T%[1]+v", iter) + for _, object := range ap.ExtractObjects(create) { + // Try to get object as vocab.Type, + // else skip handling (likely) IRI. + objType := object.GetType() + if objType == nil { continue } - // Ensure given object type is a statusable. - statusable, ok := object.(ap.Statusable) - if !ok { - // Can't (currently) Create anything other than a Statusable. ([1] is a format arg index) - log.Debugf(ctx, "object entry type (currently) unsupported: %[1]T%[1]+v", object) - continue + if statusable, ok := ap.ToStatusable(objType); ok { + return f.createStatusable(ctx, statusable, receivingAccount, requestingAccount) } - // Handle creation of statusable. - return f.createStatusable(ctx, - statusable, - receivingAccount, - requestingAccount, - ) + // TODO: handle CREATE of other types? } return nil diff --git a/internal/federation/federatingdb/db.go b/internal/federation/federatingdb/db.go index 3f35a96c3..c412ba3f8 100644 --- a/internal/federation/federatingdb/db.go +++ b/internal/federation/federatingdb/db.go @@ -34,6 +34,7 @@ type DB interface { Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error Announce(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error + Question(ctx context.Context, question vocab.ActivityStreamsQuestion) error } // FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface. diff --git a/internal/federation/federatingdb/question.go b/internal/federation/federatingdb/question.go new file mode 100644 index 000000000..85226d9ed --- /dev/null +++ b/internal/federation/federatingdb/question.go @@ -0,0 +1,32 @@ +// 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 . + +package federatingdb + +import ( + "context" + + "github.com/superseriousbusiness/activity/streams/vocab" +) + +func (f *federatingDB) Question(ctx context.Context, question vocab.ActivityStreamsQuestion) error { + receivingAccount, requestingAccount, internal := extractFromCtx(ctx) + if internal { + return nil // Already processed. + } + return f.createStatusable(ctx, question, receivingAccount, requestingAccount) +} diff --git a/internal/federation/federatingdb/undo.go b/internal/federation/federatingdb/undo.go index 84a5bdd47..a7a0f077a 100644 --- a/internal/federation/federatingdb/undo.go +++ b/internal/federation/federatingdb/undo.go @@ -27,6 +27,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" ) @@ -48,31 +49,31 @@ func (f *federatingDB) Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) return nil // Already processed. } - undoObject := undo.GetActivityStreamsObject() - if undoObject == nil { - return errors.New("UNDO: no object set on vocab.ActivityStreamsUndo") - } + var errs gtserror.MultiError - for iter := undoObject.Begin(); iter != undoObject.End(); iter = iter.Next() { - t := iter.GetType() - if t == nil { + for _, object := range ap.ExtractObjects(undo) { + // Try to get object as vocab.Type, + // else skip handling (likely) IRI. + objType := object.GetType() + if objType == nil { continue } - switch t.GetTypeName() { + switch objType.GetTypeName() { case ap.ActivityFollow: - if err := f.undoFollow(ctx, receivingAccount, undo, t); err != nil { - return err + if err := f.undoFollow(ctx, receivingAccount, undo, objType); err != nil { + errs.Appendf("error undoing follow: %w", err) } case ap.ActivityLike: - if err := f.undoLike(ctx, receivingAccount, undo, t); err != nil { - return err + if err := f.undoLike(ctx, receivingAccount, undo, objType); err != nil { + errs.Appendf("error undoing like: %w", err) } case ap.ActivityAnnounce: - // todo: undo boost / reblog / announce + // TODO: actually handle this ! + log.Warn(ctx, "skipped undo announce") case ap.ActivityBlock: - if err := f.undoBlock(ctx, receivingAccount, undo, t); err != nil { - return err + if err := f.undoBlock(ctx, receivingAccount, undo, objType); err != nil { + errs.Appendf("error undoing block: %w", err) } } } diff --git a/internal/federation/federatingdb/update.go b/internal/federation/federatingdb/update.go index 8e452eb3c..5d3d4a0ff 100644 --- a/internal/federation/federatingdb/update.go +++ b/internal/federation/federatingdb/update.go @@ -56,21 +56,18 @@ func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error { return nil // Already processed. } - switch asType.GetTypeName() { - case ap.ActorApplication, ap.ActorGroup, ap.ActorOrganization, ap.ActorPerson, ap.ActorService: - return f.updateAccountable(ctx, receivingAccount, requestingAccount, asType) + if accountable, ok := ap.ToAccountable(asType); ok { + return f.updateAccountable(ctx, receivingAccount, requestingAccount, accountable) + } + + if statusable, ok := ap.ToStatusable(asType); ok { + return f.updateStatusable(ctx, receivingAccount, requestingAccount, statusable) } return nil } -func (f *federatingDB) updateAccountable(ctx context.Context, receivingAcct *gtsmodel.Account, requestingAcct *gtsmodel.Account, asType vocab.Type) error { - // Ensure delivered asType is a valid Accountable model. - accountable, ok := asType.(ap.Accountable) - if !ok { - return gtserror.Newf("could not convert vocab.Type %T to Accountable", asType) - } - +func (f *federatingDB) updateAccountable(ctx context.Context, receivingAcct *gtsmodel.Account, requestingAcct *gtsmodel.Account, accountable ap.Accountable) error { // Extract AP URI of the updated Accountable model. idProp := accountable.GetJSONLDId() if idProp == nil || !idProp.IsIRI() { @@ -103,3 +100,43 @@ func (f *federatingDB) updateAccountable(ctx context.Context, receivingAcct *gts return nil } + +func (f *federatingDB) updateStatusable(ctx context.Context, receivingAcct *gtsmodel.Account, requestingAcct *gtsmodel.Account, statusable ap.Statusable) error { + // Extract AP URI of the updated model. + idProp := statusable.GetJSONLDId() + if idProp == nil || !idProp.IsIRI() { + return gtserror.New("invalid id prop") + } + + // Get the status URI string for lookups. + statusURI := idProp.GetIRI() + statusURIStr := statusURI.String() + + // Don't try to update local statuses. + if statusURI.Host == config.GetHost() { + return nil + } + + // Get the status we have on file for this URI string. + status, err := f.state.DB.GetStatusByURI(ctx, statusURIStr) + if err != nil { + return gtserror.Newf("error fetching status from db: %w", err) + } + + // Check that update was by the status author. + if status.AccountID != requestingAcct.ID { + return gtserror.Newf("update for %s was not requested by author", statusURIStr) + } + + // Queue an UPDATE NOTE activity to our fedi API worker, + // this will handle necessary database insertions, etc. + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ + APObjectType: ap.ObjectNote, + APActivityType: ap.ActivityUpdate, + GTSModel: status, // original status + APObjectModel: statusable, + ReceivingAccount: receivingAcct, + }) + + return nil +} diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go index fb4e5bfb9..ea19eb651 100644 --- a/internal/federation/federatingprotocol.go +++ b/internal/federation/federatingprotocol.go @@ -522,6 +522,9 @@ func (f *federator) FederatingCallbacks(ctx context.Context) (wrapped pub.Federa func(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error { return f.FederatingDB().Announce(ctx, announce) }, + func(ctx context.Context, question vocab.ActivityStreamsQuestion) error { + return f.FederatingDB().Question(ctx, question) + }, } return diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go index 4d4f7c574..ee4466b1b 100644 --- a/internal/processing/status/create.go +++ b/internal/processing/status/create.go @@ -34,70 +34,75 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/uris" + "github.com/superseriousbusiness/gotosocial/internal/util" ) // Create processes the given form to create a new status, returning the api model representation of that status if it's OK. // // Precondition: the form's fields should have already been validated and normalized by the caller. -func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, application *gtsmodel.Application, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, gtserror.WithCode) { - accountURIs := uris.GenerateURIsForAccount(account.Username) - thisStatusID := id.NewULID() - local := true - sensitive := form.Sensitive +func (p *Processor) Create(ctx context.Context, requestingAccount *gtsmodel.Account, application *gtsmodel.Application, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, gtserror.WithCode) { + // Generate new ID for status. + statusID := id.NewULID() - newStatus := >smodel.Status{ - ID: thisStatusID, - URI: accountURIs.StatusesURI + "/" + thisStatusID, - URL: accountURIs.StatusesURL + "/" + thisStatusID, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Local: &local, - AccountID: account.ID, - AccountURI: account.URI, - ContentWarning: text.SanitizeToPlaintext(form.SpoilerText), + // Generate necessary URIs for username, to build status URIs. + accountURIs := uris.GenerateURIsForAccount(requestingAccount.Username) + + // Get current time. + now := time.Now() + + status := >smodel.Status{ + ID: statusID, + URI: accountURIs.StatusesURI + "/" + statusID, + URL: accountURIs.StatusesURL + "/" + statusID, + CreatedAt: now, + UpdatedAt: now, + Local: util.Ptr(true), + Account: requestingAccount, + AccountID: requestingAccount.ID, + AccountURI: requestingAccount.URI, ActivityStreamsType: ap.ObjectNote, - Sensitive: &sensitive, + Sensitive: &form.Sensitive, CreatedWithApplicationID: application.ID, Text: form.Status, } - if errWithCode := processReplyToID(ctx, p.state.DB, form, account.ID, newStatus); errWithCode != nil { + if errWithCode := p.processReplyToID(ctx, form, requestingAccount.ID, status); errWithCode != nil { return nil, errWithCode } - if errWithCode := processMediaIDs(ctx, p.state.DB, form, account.ID, newStatus); errWithCode != nil { + if errWithCode := p.processMediaIDs(ctx, form, requestingAccount.ID, status); errWithCode != nil { return nil, errWithCode } - if err := processVisibility(ctx, form, account.Privacy, newStatus); err != nil { + if err := processVisibility(form, requestingAccount.Privacy, status); err != nil { return nil, gtserror.NewErrorInternalError(err) } - if err := processLanguage(ctx, form, account.Language, newStatus); err != nil { + if err := processLanguage(form, requestingAccount.Language, status); err != nil { return nil, gtserror.NewErrorInternalError(err) } - if err := processContent(ctx, p.state.DB, p.formatter, p.parseMention, form, account.ID, newStatus); err != nil { + if err := p.processContent(ctx, p.parseMention, form, status); err != nil { return nil, gtserror.NewErrorInternalError(err) } - // put the new status in the database - if err := p.state.DB.PutStatus(ctx, newStatus); err != nil { + // Insert this new status in the database. + if err := p.state.DB.PutStatus(ctx, status); err != nil { return nil, gtserror.NewErrorInternalError(err) } - // send it back to the processor for async processing + // send it back to the client API worker for async side-effects. p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{ APObjectType: ap.ObjectNote, APActivityType: ap.ActivityCreate, - GTSModel: newStatus, - OriginAccount: account, + GTSModel: status, + OriginAccount: requestingAccount, }) - return p.apiStatus(ctx, newStatus, account) + return p.apiStatus(ctx, status, requestingAccount) } -func processReplyToID(ctx context.Context, dbService db.DB, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode { +func (p *Processor) processReplyToID(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode { if form.InReplyToID == "" { return nil } @@ -109,78 +114,74 @@ func processReplyToID(ctx context.Context, dbService db.DB, form *apimodel.Advan // 3. Does a block exist between either the current account or the account that posted the status it's replying to? // // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing. - repliedStatus := >smodel.Status{} - repliedAccount := >smodel.Account{} - if err := dbService.GetByID(ctx, form.InReplyToID, repliedStatus); err != nil { - if err == db.ErrNoEntries { - err := fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) - return gtserror.NewErrorBadRequest(err, err.Error()) - } - err := fmt.Errorf("db error fetching status with id %s: %s", form.InReplyToID, err) - return gtserror.NewErrorInternalError(err) - } - if !*repliedStatus.Replyable { - err := fmt.Errorf("status with id %s is marked as not replyable", form.InReplyToID) - return gtserror.NewErrorForbidden(err, err.Error()) - } - - if err := dbService.GetByID(ctx, repliedStatus.AccountID, repliedAccount); err != nil { - if err == db.ErrNoEntries { - err := fmt.Errorf("status with id %s not replyable because account id %s is not known", form.InReplyToID, repliedStatus.AccountID) - return gtserror.NewErrorBadRequest(err, err.Error()) - } - err := fmt.Errorf("db error fetching account with id %s: %s", repliedStatus.AccountID, err) + inReplyTo, err := p.state.DB.GetStatusByID(ctx, form.InReplyToID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error fetching status %s from db: %w", form.InReplyToID, err) return gtserror.NewErrorInternalError(err) } - if blocked, err := dbService.IsEitherBlocked(ctx, thisAccountID, repliedAccount.ID); err != nil { - err := fmt.Errorf("db error checking block: %s", err) + if inReplyTo == nil { + const text = "cannot reply to status that does not exist" + return gtserror.NewErrorBadRequest(errors.New(text), text) + } + + if !*inReplyTo.Replyable { + text := fmt.Sprintf("status %s is marked as not replyable", form.InReplyToID) + return gtserror.NewErrorForbidden(errors.New(text), text) + } + + if blocked, err := p.state.DB.IsEitherBlocked(ctx, thisAccountID, inReplyTo.AccountID); err != nil { + err := gtserror.Newf("error checking block in db: %w", err) return gtserror.NewErrorInternalError(err) } else if blocked { - err := fmt.Errorf("status with id %s not replyable", form.InReplyToID) - return gtserror.NewErrorNotFound(err) + text := fmt.Sprintf("status %s is not replyable", form.InReplyToID) + return gtserror.NewErrorNotFound(errors.New(text), text) } - status.InReplyToID = repliedStatus.ID - status.InReplyToURI = repliedStatus.URI - status.InReplyToAccountID = repliedAccount.ID + // Set status fields from inReplyTo. + status.InReplyToID = inReplyTo.ID + status.InReplyToURI = inReplyTo.URI + status.InReplyToAccountID = inReplyTo.AccountID return nil } -func processMediaIDs(ctx context.Context, dbService db.DB, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode { +func (p *Processor) processMediaIDs(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, thisAccountID string, status *gtsmodel.Status) gtserror.WithCode { if form.MediaIDs == nil { return nil } + // Get minimum allowed char descriptions. + minChars := config.GetMediaDescriptionMinChars() + attachments := []*gtsmodel.MediaAttachment{} attachmentIDs := []string{} for _, mediaID := range form.MediaIDs { - attachment, err := dbService.GetAttachmentByID(ctx, mediaID) - if err != nil { - if errors.Is(err, db.ErrNoEntries) { - err = fmt.Errorf("ProcessMediaIDs: media not found for media id %s", mediaID) - return gtserror.NewErrorBadRequest(err, err.Error()) - } - err = fmt.Errorf("ProcessMediaIDs: db error for media id %s", mediaID) + attachment, err := p.state.DB.GetAttachmentByID(ctx, mediaID) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + err := gtserror.Newf("error fetching media from db: %w", err) return gtserror.NewErrorInternalError(err) } + if attachment == nil { + text := fmt.Sprintf("media %s not found", mediaID) + return gtserror.NewErrorBadRequest(errors.New(text), text) + } + if attachment.AccountID != thisAccountID { - err = fmt.Errorf("ProcessMediaIDs: media with id %s does not belong to account %s", mediaID, thisAccountID) - return gtserror.NewErrorBadRequest(err, err.Error()) + text := fmt.Sprintf("media %s does not belong to account", mediaID) + return gtserror.NewErrorBadRequest(errors.New(text), text) } if attachment.StatusID != "" || attachment.ScheduledStatusID != "" { - err = fmt.Errorf("ProcessMediaIDs: media with id %s is already attached to a status", mediaID) - return gtserror.NewErrorBadRequest(err, err.Error()) + text := fmt.Sprintf("media %s already attached to status", mediaID) + return gtserror.NewErrorBadRequest(errors.New(text), text) } - minDescriptionChars := config.GetMediaDescriptionMinChars() - if descriptionLength := len([]rune(attachment.Description)); descriptionLength < minDescriptionChars { - err = fmt.Errorf("ProcessMediaIDs: description too short! media description of at least %d chararacters is required but %d was provided for media with id %s", minDescriptionChars, descriptionLength, mediaID) - return gtserror.NewErrorBadRequest(err, err.Error()) + if length := len([]rune(attachment.Description)); length < minChars { + text := fmt.Sprintf("media %s description too short, at least %d required", mediaID, minChars) + return gtserror.NewErrorBadRequest(errors.New(text), text) } attachments = append(attachments, attachment) @@ -192,7 +193,7 @@ func processMediaIDs(ctx context.Context, dbService db.DB, form *apimodel.Advanc return nil } -func processVisibility(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { +func processVisibility(form *apimodel.AdvancedStatusCreateForm, accountDefaultVis gtsmodel.Visibility, status *gtsmodel.Status) error { // by default all flags are set to true federated := true boostable := true @@ -265,7 +266,7 @@ func processVisibility(ctx context.Context, form *apimodel.AdvancedStatusCreateF return nil } -func processLanguage(ctx context.Context, form *apimodel.AdvancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { +func processLanguage(form *apimodel.AdvancedStatusCreateForm, accountDefaultLanguage string, status *gtsmodel.Status) error { if form.Language != "" { status.Language = form.Language } else { @@ -277,68 +278,80 @@ func processLanguage(ctx context.Context, form *apimodel.AdvancedStatusCreateFor return nil } -func processContent(ctx context.Context, dbService db.DB, formatter *text.Formatter, parseMention gtsmodel.ParseMentionFunc, form *apimodel.AdvancedStatusCreateForm, accountID string, status *gtsmodel.Status) error { - // if there's nothing in the status at all we can just return early - if form.Status == "" { - status.Content = "" - return nil - } - - // if content type wasn't specified we should try to figure out what content type this user prefers +func (p *Processor) processContent(ctx context.Context, parseMention gtsmodel.ParseMentionFunc, form *apimodel.AdvancedStatusCreateForm, status *gtsmodel.Status) error { if form.ContentType == "" { - acct, err := dbService.GetAccountByID(ctx, accountID) - if err != nil { - return fmt.Errorf("error processing new content: couldn't retrieve account from db to check post format: %s", err) - } - - switch acct.StatusContentType { - case "text/plain": - form.ContentType = apimodel.StatusContentTypePlain - case "text/markdown": - form.ContentType = apimodel.StatusContentTypeMarkdown - default: - form.ContentType = apimodel.StatusContentTypeDefault - } + // If content type wasn't specified, use the author's preferred content-type. + contentType := apimodel.StatusContentType(status.Account.StatusContentType) + form.ContentType = contentType + } + + // format is the currently set text formatting + // function, according to the provided content-type. + var format text.FormatFunc + + // formatInput is a shorthand function to format the given input string with the + // currently set 'formatFunc', passing in all required args and returning result. + formatInput := func(formatFunc text.FormatFunc, input string) *text.FormatResult { + return formatFunc(ctx, parseMention, status.AccountID, status.ID, input) } - // parse content out of the status depending on what content type has been submitted - var f text.FormatFunc switch form.ContentType { + // None given / set, + // use default (plain). + case "": + fallthrough + + // Format status according to text/plain. case apimodel.StatusContentTypePlain: - f = formatter.FromPlain + format = p.formatter.FromPlain + + // Format status according to text/markdown. case apimodel.StatusContentTypeMarkdown: - f = formatter.FromMarkdown + format = p.formatter.FromMarkdown + + // Unknown. default: - return fmt.Errorf("format %s not recognised as a valid status format", form.ContentType) - } - formatted := f(ctx, parseMention, accountID, status.ID, form.Status) - - // add full populated gts {mentions, tags, emojis} to the status for passing them around conveniently - // add just their ids to the status for putting in the db - status.Mentions = formatted.Mentions - status.MentionIDs = make([]string, 0, len(formatted.Mentions)) - for _, gtsmention := range formatted.Mentions { - status.MentionIDs = append(status.MentionIDs, gtsmention.ID) + return fmt.Errorf("invalid status format: %q", form.ContentType) } - status.Tags = formatted.Tags - status.TagIDs = make([]string, 0, len(formatted.Tags)) - for _, gtstag := range formatted.Tags { - status.TagIDs = append(status.TagIDs, gtstag.ID) - } + // Sanitize status text and format. + contentRes := formatInput(format, form.Status) - status.Emojis = formatted.Emojis - status.EmojiIDs = make([]string, 0, len(formatted.Emojis)) - for _, gtsemoji := range formatted.Emojis { - status.EmojiIDs = append(status.EmojiIDs, gtsemoji.ID) - } + // Collect formatted results. + status.Content = contentRes.HTML + status.Mentions = append(status.Mentions, contentRes.Mentions...) + status.Emojis = append(status.Emojis, contentRes.Emojis...) + status.Tags = append(status.Tags, contentRes.Tags...) - spoilerformatted := formatter.FromPlainEmojiOnly(ctx, parseMention, accountID, status.ID, form.SpoilerText) - for _, gtsemoji := range spoilerformatted.Emojis { - status.Emojis = append(status.Emojis, gtsemoji) - status.EmojiIDs = append(status.EmojiIDs, gtsemoji.ID) - } + // From here-on-out just use emoji-only + // plain-text formatting as the FormatFunc. + format = p.formatter.FromPlainEmojiOnly + + // Sanitize content warning and format. + spoiler := text.SanitizeToPlaintext(form.SpoilerText) + warningRes := formatInput(format, spoiler) + + // Collect formatted results. + status.ContentWarning = warningRes.HTML + status.Emojis = append(status.Emojis, warningRes.Emojis...) + + // Gather all the database IDs from each of the gathered status mentions, tags, and emojis. + status.MentionIDs = gatherIDs(status.Mentions, func(mention *gtsmodel.Mention) string { return mention.ID }) + status.TagIDs = gatherIDs(status.Tags, func(tag *gtsmodel.Tag) string { return tag.ID }) + status.EmojiIDs = gatherIDs(status.Emojis, func(emoji *gtsmodel.Emoji) string { return emoji.ID }) - status.Content = formatted.HTML return nil } + +// gatherIDs is a small utility function to gather IDs from a slice of type T. +func gatherIDs[T any](in []T, getID func(T) string) []string { + if getID == nil { + // move nil check out loop. + panic("nil getID function") + } + ids := make([]string, len(in)) + for i, t := range in { + ids[i] = getID(t) + } + return ids +} diff --git a/internal/processing/status/create_test.go b/internal/processing/status/create_test.go index 2c86e5a29..8d4267585 100644 --- a/internal/processing/status/create_test.go +++ b/internal/processing/status/create_test.go @@ -204,7 +204,7 @@ func (suite *StatusCreateTestSuite) TestProcessMediaDescriptionTooShort() { } apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) - suite.EqualError(err, "ProcessMediaIDs: description too short! media description of at least 100 chararacters is required but 15 was provided for media with id 01F8MH8RMYQ6MSNY3JM2XT1CQ5") + suite.EqualError(err, "media 01F8MH8RMYQ6MSNY3JM2XT1CQ5 description too short, at least 100 required") suite.Nil(apiStatus) } diff --git a/internal/processing/stream/stream.go b/internal/processing/stream/stream.go index 972173c7a..a5b3b9386 100644 --- a/internal/processing/stream/stream.go +++ b/internal/processing/stream/stream.go @@ -46,7 +46,7 @@ func (p *Processor) toAccount(payload string, event string, streamTypes []string if !ok { return nil // No entry = nothing to stream. } - streamsForAccount := v.(*stream.StreamsForAccount) //nolint:forcetypeassert + streamsForAccount := v.(*stream.StreamsForAccount) streamsForAccount.Lock() defer streamsForAccount.Unlock() diff --git a/internal/processing/workers/federate.go b/internal/processing/workers/federate.go index 4b2ca4de1..a87a89fd2 100644 --- a/internal/processing/workers/federate.go +++ b/internal/processing/workers/federate.go @@ -147,27 +147,27 @@ func (f *federate) CreateStatus(ctx context.Context, status *gtsmodel.Status) er return nil } - // Populate model. + // Ensure the status model is fully populated. if err := f.state.DB.PopulateStatus(ctx, status); err != nil { return gtserror.Newf("error populating status: %w", err) } - // Parse relevant URI(s). + // Parse the outbox URI of the status author. outboxIRI, err := parseURI(status.Account.OutboxURI) if err != nil { return err } - // Convert status to an ActivityStreams - // Note, wrapped in a Create activity. - asStatus, err := f.converter.StatusToAS(ctx, status) + // Convert status to ActivityStreams Statusable implementing type. + statusable, err := f.converter.StatusToAS(ctx, status) if err != nil { - return gtserror.Newf("error converting status to AS: %w", err) + return gtserror.Newf("error converting status to Statusable: %w", err) } - create, err := f.converter.WrapNoteInCreate(asStatus, false) + // Use ActivityStreams Statusable type as Object of Create. + create, err := f.converter.WrapStatusableInCreate(statusable, false) if err != nil { - return gtserror.Newf("error wrapping status in create: %w", err) + return gtserror.Newf("error wrapping Statusable in Create: %w", err) } // Send the Create via the Actor's outbox. @@ -196,12 +196,12 @@ func (f *federate) DeleteStatus(ctx context.Context, status *gtsmodel.Status) er return nil } - // Populate model. + // Ensure the status model is fully populated. if err := f.state.DB.PopulateStatus(ctx, status); err != nil { return gtserror.Newf("error populating status: %w", err) } - // Parse relevant URI(s). + // Parse the outbox URI of the status author. outboxIRI, err := parseURI(status.Account.OutboxURI) if err != nil { return err @@ -226,6 +226,50 @@ func (f *federate) DeleteStatus(ctx context.Context, status *gtsmodel.Status) er return nil } +func (f *federate) UpdateStatus(ctx context.Context, status *gtsmodel.Status) error { + // Do nothing if the status + // shouldn't be federated. + if !*status.Federated { + return nil + } + + // Do nothing if this + // isn't our status. + if !*status.Local { + return nil + } + + // Ensure the status model is fully populated. + if err := f.state.DB.PopulateStatus(ctx, status); err != nil { + return gtserror.Newf("error populating status: %w", err) + } + + // Parse the outbox URI of the status author. + outboxIRI, err := parseURI(status.Account.OutboxURI) + if err != nil { + return err + } + + // Convert status to ActivityStreams Statusable implementing type. + statusable, err := f.converter.StatusToAS(ctx, status) + if err != nil { + return gtserror.Newf("error converting status to Statusable: %w", err) + } + + // Use ActivityStreams Statusable type as Object of Update. + update, err := f.converter.WrapStatusableInUpdate(statusable, false) + if err != nil { + return gtserror.Newf("error wrapping Statusable in Update: %w", err) + } + + // Send the Update activity with Statusable via the Actor's outbox. + if _, err := f.FederatingActor().Send(ctx, outboxIRI, update); err != nil { + return gtserror.Newf("error sending Update activity via outbox %s: %w", outboxIRI, err) + } + + return nil +} + func (f *federate) Follow(ctx context.Context, follow *gtsmodel.Follow) error { // Populate model. if err := f.state.DB.PopulateFollow(ctx, follow); err != nil { diff --git a/internal/processing/workers/fromclientapi.go b/internal/processing/workers/fromclientapi.go index 1c668db71..ff316b1f4 100644 --- a/internal/processing/workers/fromclientapi.go +++ b/internal/processing/workers/fromclientapi.go @@ -114,6 +114,10 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg messages.From case ap.ActivityUpdate: switch cMsg.APObjectType { + // UPDATE NOTE/STATUS + case ap.ObjectNote: + return p.clientAPI.UpdateStatus(ctx, cMsg) + // UPDATE PROFILE/ACCOUNT case ap.ObjectProfile, ap.ActorPerson: return p.clientAPI.UpdateAccount(ctx, cMsg) @@ -332,10 +336,25 @@ func (p *clientAPI) CreateBlock(ctx context.Context, cMsg messages.FromClientAPI return nil } +func (p *clientAPI) UpdateStatus(ctx context.Context, cMsg messages.FromClientAPI) error { + // Cast the updated Status model attached to msg. + status, ok := cMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return gtserror.Newf("cannot cast %T -> *gtsmodel.Status", cMsg.GTSModel) + } + + // Federate the updated status changes out remotely. + if err := p.federate.UpdateStatus(ctx, status); err != nil { + return gtserror.Newf("error federating status update: %w", err) + } + + return nil +} + func (p *clientAPI) UpdateAccount(ctx context.Context, cMsg messages.FromClientAPI) error { account, ok := cMsg.GTSModel.(*gtsmodel.Account) if !ok { - return gtserror.Newf("%T not parseable as *gtsmodel.Account", cMsg.GTSModel) + return gtserror.Newf("cannot cast %T -> *gtsmodel.Account", cMsg.GTSModel) } if err := p.federate.UpdateAccount(ctx, account); err != nil { diff --git a/internal/processing/workers/fromfediapi.go b/internal/processing/workers/fromfediapi.go index 57e087499..598480cfb 100644 --- a/internal/processing/workers/fromfediapi.go +++ b/internal/processing/workers/fromfediapi.go @@ -119,6 +119,10 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg messages.FromFe case ap.ActivityUpdate: switch fMsg.APObjectType { //nolint:gocritic + // UPDATE NOTE/STATUS + case ap.ObjectNote: + return p.fediAPI.UpdateStatus(ctx, fMsg) + // UPDATE PROFILE/ACCOUNT case ap.ObjectProfile: return p.fediAPI.UpdateAccount(ctx, fMsg) @@ -485,13 +489,13 @@ func (p *fediAPI) UpdateAccount(ctx context.Context, fMsg messages.FromFediAPI) // Parse the old/existing account model. account, ok := fMsg.GTSModel.(*gtsmodel.Account) if !ok { - return gtserror.Newf("%T not parseable as *gtsmodel.Account", fMsg.GTSModel) + return gtserror.Newf("cannot cast %T -> *gtsmodel.Account", fMsg.GTSModel) } // Because this was an Update, the new Accountable should be set on the message. apubAcc, ok := fMsg.APObjectModel.(ap.Accountable) if !ok { - return gtserror.Newf("%T not parseable as ap.Accountable", fMsg.APObjectModel) + return gtserror.Newf("cannot cast %T -> ap.Accountable", fMsg.APObjectModel) } // Fetch up-to-date bio, avatar, header, etc. @@ -509,6 +513,34 @@ func (p *fediAPI) UpdateAccount(ctx context.Context, fMsg messages.FromFediAPI) return nil } +func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg messages.FromFediAPI) error { + // Cast the existing Status model attached to msg. + existing, ok := fMsg.GTSModel.(*gtsmodel.Status) + if !ok { + return gtserror.Newf("cannot cast %T -> *gtsmodel.Status", fMsg.GTSModel) + } + + // Cast the updated ActivityPub statusable object . + apStatus, ok := fMsg.APObjectModel.(ap.Statusable) + if !ok { + return gtserror.Newf("cannot cast %T -> ap.Statusable", fMsg.APObjectModel) + } + + // Fetch up-to-date attach status attachments, etc. + _, _, err := p.federate.RefreshStatus( + ctx, + fMsg.ReceivingAccount.Username, + existing, + apStatus, + false, + ) + if err != nil { + return gtserror.Newf("error refreshing updated status: %w", err) + } + + return nil +} + func (p *fediAPI) DeleteStatus(ctx context.Context, fMsg messages.FromFediAPI) error { // Delete attachments from this status, since this request // comes from the federating API, and there's no way the diff --git a/internal/processing/workers/wipestatus.go b/internal/processing/workers/wipestatus.go index 0891d9e24..ab59f14be 100644 --- a/internal/processing/workers/wipestatus.go +++ b/internal/processing/workers/wipestatus.go @@ -38,7 +38,7 @@ func wipeStatusF(state *state.State, media *media.Processor, surface *surface) w statusToDelete *gtsmodel.Status, deleteAttachments bool, ) error { - errs := new(gtserror.MultiError) + var errs gtserror.MultiError // Either delete all attachments for this status, // or simply unattach + clean them separately later. @@ -48,15 +48,15 @@ func wipeStatusF(state *state.State, media *media.Processor, surface *surface) w // status immediately (in case of delete + redraft) if deleteAttachments { // todo:state.DB.DeleteAttachmentsForStatus - for _, a := range statusToDelete.AttachmentIDs { - if err := media.Delete(ctx, a); err != nil { + for _, id := range statusToDelete.AttachmentIDs { + if err := media.Delete(ctx, id); err != nil { errs.Appendf("error deleting media: %w", err) } } } else { // todo:state.DB.UnattachAttachmentsForStatus - for _, a := range statusToDelete.AttachmentIDs { - if _, err := media.Unattach(ctx, statusToDelete.Account, a); err != nil { + for _, id := range statusToDelete.AttachmentIDs { + if _, err := media.Unattach(ctx, statusToDelete.Account, id); err != nil { errs.Appendf("error unattaching media: %w", err) } } @@ -95,11 +95,12 @@ func wipeStatusF(state *state.State, media *media.Processor, surface *surface) w if err != nil { errs.Appendf("error fetching status boosts: %w", err) } - for _, b := range boosts { - if err := surface.deleteStatusFromTimelines(ctx, b.ID); err != nil { + + for _, boost := range boosts { + if err := surface.deleteStatusFromTimelines(ctx, boost.ID); err != nil { errs.Appendf("error deleting boost from timelines: %w", err) } - if err := state.DB.DeleteStatusByID(ctx, b.ID); err != nil { + if err := state.DB.DeleteStatusByID(ctx, boost.ID); err != nil { errs.Appendf("error deleting boost: %w", err) } } diff --git a/internal/text/plain.go b/internal/text/plain.go index 1456fd016..1935cec8c 100644 --- a/internal/text/plain.go +++ b/internal/text/plain.go @@ -21,6 +21,7 @@ import ( "bytes" "context" + "codeberg.org/gruf/go-byteutil" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" "github.com/yuin/goldmark" @@ -103,11 +104,11 @@ func (f *Formatter) FromPlainEmojiOnly( statusID string, input string, ) *FormatResult { - // Initialize standard block parser - // that wraps result in

tags. + // Initialize block parser that + // doesn't wrap result in

tags. plainTextParser := parser.NewParser( parser.WithBlockParsers( - util.Prioritized(newPlaintextParser(), 500), + util.Prioritized(newPlaintextParserNoParagraph(), 500), ), ) @@ -161,17 +162,21 @@ func (f *Formatter) fromPlain( ), ) + // Convert input string to bytes + // without performing any allocs. + bInput := byteutil.S2B(input) + // Parse input into HTML. var htmlBytes bytes.Buffer if err := md.Convert( - []byte(input), + bInput, &htmlBytes, ); err != nil { log.Errorf(ctx, "error formatting plaintext input to HTML: %s", err) } // Clean and shrink HTML. - result.HTML = htmlBytes.String() + result.HTML = byteutil.B2S(htmlBytes.Bytes()) result.HTML = SanitizeToHTML(result.HTML) result.HTML = MinifyHTML(result.HTML) diff --git a/internal/timeline/get.go b/internal/timeline/get.go index bc238c276..93c869e73 100644 --- a/internal/timeline/get.go +++ b/internal/timeline/get.go @@ -222,7 +222,7 @@ func (t *timeline) getXBetweenIDs(ctx context.Context, amount int, behindID stri // a point where the items are out of the range // we're interested in. rangeF = func(e *list.Element) (bool, error) { - entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert + entry := e.Value.(*indexedItemsEntry) if entry.itemID >= behindID { // ID of this item is too high, @@ -276,7 +276,6 @@ func (t *timeline) getXBetweenIDs(ctx context.Context, amount int, behindID stri // Move the mark back one place each loop. beforeIDMark = e - //nolint:forcetypeassert if entry := e.Value.(*indexedItemsEntry); entry.itemID <= beforeID { // We've gone as far as we can through // the list and reached entries that are @@ -319,7 +318,7 @@ func (t *timeline) getXBetweenIDs(ctx context.Context, amount int, behindID stri // To preserve ordering, we need to reverse the slice // when we're finished. for e := beforeIDMark; e != nil; e = e.Prev() { - entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert + entry := e.Value.(*indexedItemsEntry) if entry.itemID == beforeID { // Don't include the beforeID diff --git a/internal/timeline/index.go b/internal/timeline/index.go index 993f7dc5d..6abb6d28d 100644 --- a/internal/timeline/index.go +++ b/internal/timeline/index.go @@ -65,7 +65,7 @@ func (t *timeline) indexXBetweenIDs(ctx context.Context, amount int, behindID st ) for e := t.items.data.Front(); e != nil; e = e.Next() { - entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert + entry := e.Value.(*indexedItemsEntry) position++ @@ -174,7 +174,6 @@ func (t *timeline) grab(ctx context.Context, amount int, behindID string, before // Don't grab more than we need to. amount-grabbed, ) - if err != nil { // Grab function already checks for // db.ErrNoEntries, so if an error @@ -280,5 +279,5 @@ func (t *timeline) OldestIndexedItemID() string { return "" } - return e.Value.(*indexedItemsEntry).itemID //nolint:forcetypeassert + return e.Value.(*indexedItemsEntry).itemID } diff --git a/internal/timeline/indexeditems.go b/internal/timeline/indexeditems.go index c2c7a19b6..9b75e7256 100644 --- a/internal/timeline/indexeditems.go +++ b/internal/timeline/indexeditems.go @@ -65,7 +65,7 @@ func (i *indexedItems) insertIndexed(ctx context.Context, newEntry *indexedItems for e := i.data.Front(); e != nil; e = e.Next() { currentPosition++ - currentEntry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert + currentEntry := e.Value.(*indexedItemsEntry) // Check if we need to skip inserting this item based on // the current item. diff --git a/internal/timeline/manager.go b/internal/timeline/manager.go index df0323cdb..b4f075138 100644 --- a/internal/timeline/manager.go +++ b/internal/timeline/manager.go @@ -219,7 +219,6 @@ func (m *manager) UnprepareItemFromAllTimelines(ctx context.Context, itemID stri // Work through all timelines held by this // manager, and call Unprepare for each. m.timelines.Range(func(_ any, v any) bool { - // nolint:forcetypeassert if err := v.(Timeline).Unprepare(ctx, itemID); err != nil { errs.Append(err) } @@ -248,7 +247,7 @@ func (m *manager) getOrCreateTimeline(ctx context.Context, timelineID string) Ti i, ok := m.timelines.Load(timelineID) if ok { // Timeline already existed in sync.Map. - return i.(Timeline) //nolint:forcetypeassert + return i.(Timeline) } // Timeline did not yet exist in sync.Map. diff --git a/internal/timeline/prepare.go b/internal/timeline/prepare.go index 3344a0e73..07bde79fa 100644 --- a/internal/timeline/prepare.go +++ b/internal/timeline/prepare.go @@ -63,7 +63,7 @@ func (t *timeline) prepareXBetweenIDs(ctx context.Context, amount int, behindID if frontToBack { // Paging forwards / down. for e := t.items.data.Front(); e != nil; e = e.Next() { - entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert + entry := e.Value.(*indexedItemsEntry) if entry.itemID > behindID { l.Trace("item is too new, continuing") @@ -91,7 +91,7 @@ func (t *timeline) prepareXBetweenIDs(ctx context.Context, amount int, behindID } else { // Paging backwards / up. for e := t.items.data.Back(); e != nil; e = e.Prev() { - entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert + entry := e.Value.(*indexedItemsEntry) if entry.itemID < beforeID { l.Trace("item is too old, continuing") diff --git a/internal/timeline/prune.go b/internal/timeline/prune.go index a3a5bf9cb..5c7476956 100644 --- a/internal/timeline/prune.go +++ b/internal/timeline/prune.go @@ -63,7 +63,7 @@ func (t *timeline) Prune(desiredPreparedItemsLength int, desiredIndexedItemsLeng continue } - entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert + entry := e.Value.(*indexedItemsEntry) if entry.prepared == nil { // It's already unprepared (mood). continue diff --git a/internal/timeline/remove.go b/internal/timeline/remove.go index 693c9f9b9..86352b9fa 100644 --- a/internal/timeline/remove.go +++ b/internal/timeline/remove.go @@ -42,7 +42,7 @@ func (t *timeline) Remove(ctx context.Context, statusID string) (int, error) { var toRemove []*list.Element for e := t.items.data.Front(); e != nil; e = e.Next() { - entry := e.Value.(*indexedItemsEntry) // nolint:forcetypeassert + entry := e.Value.(*indexedItemsEntry) if entry.itemID != statusID { // Not relevant. @@ -78,7 +78,7 @@ func (t *timeline) RemoveAllByOrBoosting(ctx context.Context, accountID string) var toRemove []*list.Element for e := t.items.data.Front(); e != nil; e = e.Next() { - entry := e.Value.(*indexedItemsEntry) // nolint:forcetypeassert + entry := e.Value.(*indexedItemsEntry) if entry.accountID != accountID && entry.boostOfAccountID != accountID { // Not relevant. diff --git a/internal/timeline/unprepare.go b/internal/timeline/unprepare.go index 827b274d8..67a990287 100644 --- a/internal/timeline/unprepare.go +++ b/internal/timeline/unprepare.go @@ -31,7 +31,7 @@ func (t *timeline) Unprepare(ctx context.Context, itemID string) error { } for e := t.items.data.Front(); e != nil; e = e.Next() { - entry := e.Value.(*indexedItemsEntry) // nolint:forcetypeassert + entry := e.Value.(*indexedItemsEntry) if entry.itemID != itemID && entry.boostOfID != itemID { // Not relevant. diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index 81dbc6f40..92465c790 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -216,40 +216,10 @@ func (c *Converter) ASRepresentationToAccount(ctx context.Context, accountable a return acct, nil } -func (c *Converter) extractAttachments(i ap.WithAttachment) []*gtsmodel.MediaAttachment { - attachmentProp := i.GetActivityStreamsAttachment() - if attachmentProp == nil { - return nil - } - - attachments := make([]*gtsmodel.MediaAttachment, 0, attachmentProp.Len()) - - for iter := attachmentProp.Begin(); iter != attachmentProp.End(); iter = iter.Next() { - t := iter.GetType() - if t == nil { - continue - } - - attachmentable, ok := t.(ap.Attachmentable) - if !ok { - log.Error(nil, "ap attachment was not attachmentable") - continue - } - - attachment, err := ap.ExtractAttachment(attachmentable) - if err != nil { - log.Errorf(nil, "error extracting attachment: %s", err) - continue - } - - attachments = append(attachments, attachment) - } - - return attachments -} - // ASStatus converts a remote activitystreams 'status' representation into a gts model status. func (c *Converter) ASStatusToStatus(ctx context.Context, statusable ap.Statusable) (*gtsmodel.Status, error) { + var err error + status := new(gtsmodel.Status) // status.URI @@ -281,7 +251,19 @@ func (c *Converter) ASStatusToStatus(ctx context.Context, statusable ap.Statusab // status.Attachments // // Media attachments for later dereferencing. - status.Attachments = c.extractAttachments(statusable) + status.Attachments, err = ap.ExtractAttachments(statusable) + if err != nil { + l.Warnf("error(s) extracting attachments: %v", err) + } + + // status.Poll + // + // Attached poll information (the statusable will actually + // be a Pollable, as a Question is a subset of our Status). + if pollable, ok := ap.ToPollable(statusable); ok { + // TODO: handle decoding poll data + _ = pollable + } // status.Hashtags // @@ -341,7 +323,7 @@ func (c *Converter) ASStatusToStatus(ctx context.Context, statusable ap.Statusab // error if we don't. attributedTo, err := ap.ExtractAttributedToURI(statusable) if err != nil { - return nil, gtserror.Newf("%w", err) + return nil, gtserror.Newf("error extracting attributed to uri: %w", err) } accountURI := attributedTo.String() diff --git a/internal/typeutils/internaltoas.go b/internal/typeutils/internaltoas.go index d82fe8e04..b920d9a0e 100644 --- a/internal/typeutils/internaltoas.go +++ b/internal/typeutils/internaltoas.go @@ -29,6 +29,7 @@ import ( "github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtserror" @@ -403,21 +404,15 @@ func (c *Converter) AccountToASMinimal(ctx context.Context, a *gtsmodel.Account) return person, nil } -// StatusToAS converts a gts model status into an activity streams note, suitable for federation -func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) { - // ensure prerequisites here before we get stuck in - - // check if author account is already attached to status and attach it if not - // if we can't retrieve this, bail here already because we can't attribute the status to anyone - if s.Account == nil { - a, err := c.state.DB.GetAccountByID(ctx, s.AccountID) - if err != nil { - return nil, gtserror.Newf("error retrieving author account from db: %w", err) - } - s.Account = a +// StatusToAS converts a gts model status into an ActivityStreams Statusable implementation, suitable for federation +func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (ap.Statusable, error) { + // Ensure the status model is fully populated. + // The status and poll models are REQUIRED so nothing to do if this fails. + if err := c.state.DB.PopulateStatus(ctx, s); err != nil { + return nil, gtserror.Newf("error populating status: %w", err) } - // create the Note! + // We convert it as an AS Note. status := streams.NewActivityStreamsNote() // id @@ -529,7 +524,6 @@ func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A } tagProp.AppendTootHashtag(asHashtag) } - status.SetActivityStreamsTag(tagProp) // parse out some URIs we need here @@ -1419,7 +1413,7 @@ func (c *Converter) StatusesToASOutboxPage(ctx context.Context, outboxID string, return nil, err } - create, err := c.WrapNoteInCreate(note, true) + create, err := c.WrapStatusableInCreate(note, true) if err != nil { return nil, err } diff --git a/internal/typeutils/wrap.go b/internal/typeutils/wrap.go index 67d3c2b5c..128c4ef15 100644 --- a/internal/typeutils/wrap.go +++ b/internal/typeutils/wrap.go @@ -44,7 +44,6 @@ func (c *Converter) WrapPersonInUpdate(person vocab.ActivityStreamsPerson, origi update.SetActivityStreamsActor(actorProp) // set the ID - newID, err := id.NewRandomULID() if err != nil { return nil, err @@ -85,26 +84,29 @@ func (c *Converter) WrapPersonInUpdate(person vocab.ActivityStreamsPerson, origi return update, nil } -// WrapNoteInCreate wraps a Note with a Create activity. +// WrapNoteInCreate wraps a Statusable with a Create activity. // // If objectIRIOnly is set to true, then the function won't put the *entire* note in the Object field of the Create, // but just the AP URI of the note. This is useful in cases where you want to give a remote server something to dereference, // and still have control over whether or not they're allowed to actually see the contents. -func (c *Converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOnly bool) (vocab.ActivityStreamsCreate, error) { +func (c *Converter) WrapStatusableInCreate(status ap.Statusable, objectIRIOnly bool) (vocab.ActivityStreamsCreate, error) { create := streams.NewActivityStreamsCreate() // Object property objectProp := streams.NewActivityStreamsObjectProperty() if objectIRIOnly { - objectProp.AppendIRI(note.GetJSONLDId().GetIRI()) + // Only append the object IRI to objectProp. + objectProp.AppendIRI(status.GetJSONLDId().GetIRI()) } else { - objectProp.AppendActivityStreamsNote(note) + // Our statusable's are always note types. + asNote := status.(vocab.ActivityStreamsNote) + objectProp.AppendActivityStreamsNote(asNote) } create.SetActivityStreamsObject(objectProp) // ID property idProp := streams.NewJSONLDIdProperty() - createID := note.GetJSONLDId().GetIRI().String() + "/activity" + createID := status.GetJSONLDId().GetIRI().String() + "/activity" createIDIRI, err := url.Parse(createID) if err != nil { return nil, err @@ -114,7 +116,7 @@ func (c *Converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOn // Actor Property actorProp := streams.NewActivityStreamsActorProperty() - actorIRI, err := ap.ExtractAttributedToURI(note) + actorIRI, err := ap.ExtractAttributedToURI(status) if err != nil { return nil, gtserror.Newf("couldn't extract AttributedTo: %w", err) } @@ -123,7 +125,7 @@ func (c *Converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOn // Published Property publishedProp := streams.NewActivityStreamsPublishedProperty() - published, err := ap.ExtractPublished(note) + published, err := ap.ExtractPublished(status) if err != nil { return nil, gtserror.Newf("couldn't extract Published: %w", err) } @@ -132,7 +134,7 @@ func (c *Converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOn // To Property toProp := streams.NewActivityStreamsToProperty() - if toURIs := ap.ExtractToURIs(note); len(toURIs) != 0 { + if toURIs := ap.ExtractToURIs(status); len(toURIs) != 0 { for _, toURI := range toURIs { toProp.AppendIRI(toURI) } @@ -141,7 +143,7 @@ func (c *Converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOn // Cc Property ccProp := streams.NewActivityStreamsCcProperty() - if ccURIs := ap.ExtractCcURIs(note); len(ccURIs) != 0 { + if ccURIs := ap.ExtractCcURIs(status); len(ccURIs) != 0 { for _, ccURI := range ccURIs { ccProp.AppendIRI(ccURI) } @@ -150,3 +152,64 @@ func (c *Converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOn return create, nil } + +// WrapStatusableInUpdate wraps a Statusable with an Update activity. +// +// If objectIRIOnly is set to true, then the function won't put the *entire* note in the Object field of the Create, +// but just the AP URI of the note. This is useful in cases where you want to give a remote server something to dereference, +// and still have control over whether or not they're allowed to actually see the contents. +func (c *Converter) WrapStatusableInUpdate(status ap.Statusable, objectIRIOnly bool) (vocab.ActivityStreamsUpdate, error) { + update := streams.NewActivityStreamsUpdate() + + // Object property + objectProp := streams.NewActivityStreamsObjectProperty() + if objectIRIOnly { + objectProp.AppendIRI(status.GetJSONLDId().GetIRI()) + } else if _, ok := status.(ap.Pollable); ok { + asQuestion := status.(vocab.ActivityStreamsQuestion) + objectProp.AppendActivityStreamsQuestion(asQuestion) + } else { + asNote := status.(vocab.ActivityStreamsNote) + objectProp.AppendActivityStreamsNote(asNote) + } + update.SetActivityStreamsObject(objectProp) + + // ID property + idProp := streams.NewJSONLDIdProperty() + createID := status.GetJSONLDId().GetIRI().String() + "/activity" + createIDIRI, err := url.Parse(createID) + if err != nil { + return nil, err + } + idProp.SetIRI(createIDIRI) + update.SetJSONLDId(idProp) + + // Actor Property + actorProp := streams.NewActivityStreamsActorProperty() + actorIRI, err := ap.ExtractAttributedToURI(status) + if err != nil { + return nil, gtserror.Newf("couldn't extract AttributedTo: %w", err) + } + actorProp.AppendIRI(actorIRI) + update.SetActivityStreamsActor(actorProp) + + // To Property + toProp := streams.NewActivityStreamsToProperty() + if toURIs := ap.ExtractToURIs(status); len(toURIs) != 0 { + for _, toURI := range toURIs { + toProp.AppendIRI(toURI) + } + update.SetActivityStreamsTo(toProp) + } + + // Cc Property + ccProp := streams.NewActivityStreamsCcProperty() + if ccURIs := ap.ExtractCcURIs(status); len(ccURIs) != 0 { + for _, ccURI := range ccURIs { + ccProp.AppendIRI(ccURI) + } + update.SetActivityStreamsCc(ccProp) + } + + return update, nil +} diff --git a/internal/typeutils/wrap_test.go b/internal/typeutils/wrap_test.go index 46d28f5c4..51f67f455 100644 --- a/internal/typeutils/wrap_test.go +++ b/internal/typeutils/wrap_test.go @@ -36,7 +36,7 @@ func (suite *WrapTestSuite) TestWrapNoteInCreateIRIOnly() { note, err := suite.typeconverter.StatusToAS(context.Background(), testStatus) suite.NoError(err) - create, err := suite.typeconverter.WrapNoteInCreate(note, true) + create, err := suite.typeconverter.WrapStatusableInCreate(note, true) suite.NoError(err) suite.NotNil(create) @@ -64,7 +64,7 @@ func (suite *WrapTestSuite) TestWrapNoteInCreate() { note, err := suite.typeconverter.StatusToAS(context.Background(), testStatus) suite.NoError(err) - create, err := suite.typeconverter.WrapNoteInCreate(note, false) + create, err := suite.typeconverter.WrapStatusableInCreate(note, false) suite.NoError(err) suite.NotNil(create)