[feature] tentatively start adding polls support (#2249)

This commit is contained in:
kim 2023-10-04 13:09:42 +01:00 committed by GitHub
parent 297b6eeaaa
commit c6e00afc7c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 657 additions and 393 deletions

View file

@ -14,7 +14,6 @@ run:
linters: linters:
# enable some extra linters, see here for the list: https://golangci-lint.run/usage/linters/ # enable some extra linters, see here for the list: https://golangci-lint.run/usage/linters/
enable: enable:
- forcetypeassert
- goconst - goconst
- gocritic - gocritic
- gofmt - gofmt

View file

@ -32,10 +32,10 @@
func ToCollectionPageIterator(t vocab.Type) (CollectionPageIterator, error) { func ToCollectionPageIterator(t vocab.Type) (CollectionPageIterator, error) {
switch name := t.GetTypeName(); name { switch name := t.GetTypeName(); name {
case ObjectCollectionPage: case ObjectCollectionPage:
t := t.(vocab.ActivityStreamsCollectionPage) //nolint:forcetypeassert t := t.(vocab.ActivityStreamsCollectionPage)
return WrapCollectionPage(t), nil return WrapCollectionPage(t), nil
case ObjectOrderedCollectionPage: case ObjectOrderedCollectionPage:
t := t.(vocab.ActivityStreamsOrderedCollectionPage) //nolint:forcetypeassert t := t.(vocab.ActivityStreamsOrderedCollectionPage)
return WrapOrderedCollectionPage(t), nil return WrapOrderedCollectionPage(t), nil
default: default:
return nil, fmt.Errorf("%T(%s) was not CollectionPage-like", t, name) return nil, fmt.Errorf("%T(%s) was not CollectionPage-like", t, name)
@ -74,7 +74,7 @@ func (iter *regularCollectionPageIterator) PrevPage() WithIRI {
return iter.GetActivityStreamsPrev() return iter.GetActivityStreamsPrev()
} }
func (iter *regularCollectionPageIterator) NextItem() IteratorItemable { func (iter *regularCollectionPageIterator) NextItem() TypeOrIRI {
if !iter.initItems() { if !iter.initItems() {
return nil return nil
} }
@ -83,7 +83,7 @@ func (iter *regularCollectionPageIterator) NextItem() IteratorItemable {
return cur return cur
} }
func (iter *regularCollectionPageIterator) PrevItem() IteratorItemable { func (iter *regularCollectionPageIterator) PrevItem() TypeOrIRI {
if !iter.initItems() { if !iter.initItems() {
return nil return nil
} }
@ -130,7 +130,7 @@ func (iter *orderedCollectionPageIterator) PrevPage() WithIRI {
return iter.GetActivityStreamsPrev() return iter.GetActivityStreamsPrev()
} }
func (iter *orderedCollectionPageIterator) NextItem() IteratorItemable { func (iter *orderedCollectionPageIterator) NextItem() TypeOrIRI {
if !iter.initItems() { if !iter.initItems() {
return nil return nil
} }
@ -139,7 +139,7 @@ func (iter *orderedCollectionPageIterator) NextItem() IteratorItemable {
return cur return cur
} }
func (iter *orderedCollectionPageIterator) PrevItem() IteratorItemable { func (iter *orderedCollectionPageIterator) PrevItem() TypeOrIRI {
if !iter.initItems() { if !iter.initItems() {
return nil return nil
} }

View file

@ -35,39 +35,56 @@
"github.com/superseriousbusiness/gotosocial/internal/util" "github.com/superseriousbusiness/gotosocial/internal/util"
) )
// ExtractObject will extract an object vocab.Type from given implementing interface. // ExtractObjects will extract object vocab.Types from given implementing interface.
func ExtractObject(with WithObject) vocab.Type { func ExtractObjects(with WithObject) []TypeOrIRI {
// Extract the attached object (if any). // Extract the attached object (if any).
obj := with.GetActivityStreamsObject() objProp := with.GetActivityStreamsObject()
if obj == nil { if objProp == nil {
return nil return nil
} }
// Only support single // Check for zero len.
// objects (for now...) if objProp.Len() == 0 {
if obj.Len() != 1 {
return nil return nil
} }
// Extract object vocab.Type. // Accumulate all of the objects into a slice.
return obj.At(0).GetType() 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. // 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(); { switch typeName := activity.GetTypeName(); {
// Activity (has "object"). // Activity (has "object").
case isActivity(typeName): case isActivity(typeName):
objType := ExtractObject(activity) objTypes := ExtractObjects(activity)
if objType == nil { if len(objTypes) == 0 {
return nil, nil, false 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"). // IntransitiveAcitivity (no "object").
case isIntransitiveActivity(typeName): case isIntransitiveActivity(typeName):
return activity, rawJSON, false asTypeOrIRI := _TypeOrIRI{activity} // wrap activity.
return []TypeOrIRI{&asTypeOrIRI}, []any{rawJSON}, true
// Unknown. // Unknown.
default: default:

View file

@ -247,14 +247,8 @@ type CollectionPageIterator interface {
NextPage() WithIRI NextPage() WithIRI
PrevPage() WithIRI PrevPage() WithIRI
NextItem() IteratorItemable NextItem() TypeOrIRI
PrevItem() IteratorItemable PrevItem() TypeOrIRI
}
// IteratorItemable represents the minimum interface for an item in an iterator.
type IteratorItemable interface {
WithIRI
WithType
} }
// Flaggable represents the minimum interface for an activitystreams 'Flag' activity. // Flaggable represents the minimum interface for an activitystreams 'Flag' activity.
@ -267,6 +261,12 @@ type Flaggable interface {
WithObject 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. // WithJSONLDId represents an activity with JSONLDIdProperty.
type WithJSONLDId interface { type WithJSONLDId interface {
GetJSONLDId() vocab.JSONLDIdProperty GetJSONLDId() vocab.JSONLDIdProperty

View file

@ -39,60 +39,48 @@
// 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. // 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{}) { func NormalizeIncomingActivity(activity pub.Activity, rawJSON map[string]interface{}) {
// From the activity extract the data vocab.Type + its "raw" JSON. // From the activity extract the data vocab.Type + its "raw" JSON.
dataType, rawData, ok := ExtractActivityData(activity, rawJSON) dataIfaces, rawData, ok := ExtractActivityData(activity, rawJSON)
if !ok { if !ok || len(dataIfaces) != len(rawData) {
// non-equal lengths *shouldn't* happen,
// but this is just an integrity check.
return return
} }
switch dataType.GetTypeName() { // Iterate over the available data.
// "Pollable" types. for i, dataIface := range dataIfaces {
case ActivityQuestion: // Try to get as vocab.Type, else
pollable, ok := dataType.(Pollable) // skip this entry for normalization.
if !ok { dataType := dataIface.GetType()
return if dataType == nil {
continue
} }
// Normalize the Pollable specific properties. // Get the raw data map at index, else skip
NormalizeIncomingPollOptions(pollable, rawData) // this entry due to impossible normalization.
rawData, ok := rawData[i].(map[string]any)
// Fallthrough to handle
// the rest as Statusable.
fallthrough
// "Statusable" types.
case ObjectArticle,
ObjectDocument,
ObjectImage,
ObjectVideo,
ObjectNote,
ObjectPage,
ObjectEvent,
ObjectPlace,
ObjectProfile:
statusable, ok := dataType.(Statusable)
if !ok { if !ok {
return continue
} }
// Normalize everything we can on the statusable. if statusable, ok := ToStatusable(dataType); ok {
NormalizeIncomingContent(statusable, rawData) if pollable, ok := ToPollable(dataType); ok {
NormalizeIncomingAttachments(statusable, rawData) // Normalize the Pollable specific properties.
NormalizeIncomingSummary(statusable, rawData) NormalizeIncomingPollOptions(pollable, rawData)
NormalizeIncomingName(statusable, rawData) }
// "Accountable" types. // Normalize everything we can on the statusable.
case ActorApplication, NormalizeIncomingContent(statusable, rawData)
ActorGroup, NormalizeIncomingAttachments(statusable, rawData)
ActorOrganization, NormalizeIncomingSummary(statusable, rawData)
ActorPerson, NormalizeIncomingName(statusable, rawData)
ActorService: continue
accountable, ok := dataType.(Accountable)
if !ok {
return
} }
// Normalize everything we can on the accountable. if accountable, ok := ToAccountable(dataType); ok {
NormalizeIncomingSummary(accountable, rawData) // Normalize everything we can on the accountable.
NormalizeIncomingSummary(accountable, rawData)
continue
}
} }
} }

43
internal/ap/util.go Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
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) {}

View file

@ -144,7 +144,6 @@ func (m *Module) createDomainPermissions(
if multiStatus.Metadata.Failure != 0 { if multiStatus.Metadata.Failure != 0 {
failures := make(map[string]any, multiStatus.Metadata.Failure) failures := make(map[string]any, multiStatus.Metadata.Failure)
for _, entry := range multiStatus.Data { for _, entry := range multiStatus.Data {
// nolint:forcetypeassert
failures[entry.Resource.(string)] = entry.Message failures[entry.Resource.(string)] = entry.Message
} }

View file

@ -290,7 +290,7 @@ func (suite *StatusCreateTestSuite) TestReplyToNonexistentStatus() {
defer result.Body.Close() defer result.Body.Close()
b, err := ioutil.ReadAll(result.Body) b, err := ioutil.ReadAll(result.Body)
suite.NoError(err) 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. // Post a reply to the status of a local user that allows replies.

View file

@ -288,8 +288,8 @@ func (d *deref) enrichStatus(
return nil, nil, gtserror.Newf("error populating mentions for status %s: %w", uri, err) return nil, nil, gtserror.Newf("error populating mentions for status %s: %w", uri, err)
} }
// Ensure the status' tags are populated. // Ensure the status' tags are populated, (changes are expected / okay).
if err := d.fetchStatusTags(ctx, requestUser, latestStatus); err != nil { if err := d.fetchStatusTags(ctx, latestStatus); err != nil {
return nil, nil, gtserror.Newf("error populating tags for status %s: %w", uri, err) 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) 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. // Ensure the status' emoji attachments are populated, (changes are expected / okay).
if err := d.fetchStatusEmojis(ctx, requestUser, status, latestStatus); err != nil { if err := d.fetchStatusEmojis(ctx, requestUser, latestStatus); err != nil {
return nil, nil, gtserror.Newf("error populating emojis for status %s: %w", uri, err) 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. // 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) mention.ID, err = id.NewULIDFromTime(status.CreatedAt)
if err != nil { if err != nil {
log.Errorf(ctx, "invalid created at date: %v", err) 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 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. // Allocate new slice to take the yet-to-be determined tag IDs.
status.TagIDs = make([]string, len(status.Tags)) status.TagIDs = make([]string, len(status.Tags))
@ -417,13 +419,14 @@ func (d *deref) fetchStatusTags(ctx context.Context, requestUser string, status
continue continue
} }
// No tag with this name yet, create it.
if tag == nil { if tag == nil {
// Create new ID for tag name.
tag = &gtsmodel.Tag{ tag = &gtsmodel.Tag{
ID: id.NewULID(), ID: id.NewULID(),
Name: placeholder.Name, Name: placeholder.Name,
} }
// Insert this tag with new name into the database.
if err := d.state.DB.PutTag(ctx, tag); err != nil { if err := d.state.DB.PutTag(ctx, tag); err != nil {
log.Errorf(ctx, "db error putting tag %s: %v", tag.Name, err) log.Errorf(ctx, "db error putting tag %s: %v", tag.Name, err)
continue continue
@ -516,7 +519,7 @@ func (d *deref) fetchStatusAttachments(ctx context.Context, tsport transport.Tra
return nil 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. // Fetch the full-fleshed-out emoji objects for our status.
emojis, err := d.populateEmojis(ctx, status.Emojis, requestUser) emojis, err := d.populateEmojis(ctx, status.Emojis, requestUser)
if err != nil { if err != nil {

View file

@ -46,28 +46,29 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA
return nil // Already processed. return nil // Already processed.
} }
acceptObject := accept.GetActivityStreamsObject() // Iterate all provided objects in the activity.
if acceptObject == nil { for _, object := range ap.ExtractObjects(accept) {
return errors.New("ACCEPT: no object set on vocab.ActivityStreamsAccept")
}
for iter := acceptObject.Begin(); iter != acceptObject.End(); iter = iter.Next() { // Check and handle any vocab.Type objects.
// check if the object is an IRI if objType := object.GetType(); objType != nil {
if iter.IsIRI() { switch objType.GetTypeName() { //nolint:gocritic
// we have just the URI of whatever is being accepted, so we need to find out what it is
acceptedObjectIRI := iter.GetIRI() case ap.ActivityFollow:
if uris.IsFollowPath(acceptedObjectIRI) { // Cast the vocab.Type object to known AS type.
// ACCEPT FOLLOW asFollow := objType.(vocab.ActivityStreamsFollow)
followReq, err := f.state.DB.GetFollowRequestByURI(ctx, acceptedObjectIRI.String())
// convert the follow to something we can understand
gtsFollow, err := f.converter.ASFollowToFollow(ctx, asFollow)
if err != nil { 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 // 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") 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 { if err != nil {
return err return err
} }
@ -78,31 +79,36 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA
GTSModel: follow, GTSModel: follow,
ReceivingAccount: receivingAccount, ReceivingAccount: receivingAccount,
}) })
return nil
} }
}
// check if iter is an AP object / type
if iter.GetType() == nil {
continue 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 // ACCEPT FOLLOW
asFollow, ok := iter.GetType().(vocab.ActivityStreamsFollow) followReq, err := f.state.DB.GetFollowRequestByURI(ctx, iriStr)
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)
if err != nil { 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 // 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") 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 { if err != nil {
return err return err
} }
@ -114,8 +120,9 @@ func (f *federatingDB) Accept(ctx context.Context, accept vocab.ActivityStreamsA
ReceivingAccount: receivingAccount, ReceivingAccount: receivingAccount,
}) })
return nil continue
} }
} }
return nil return nil

View file

@ -81,6 +81,7 @@ func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error {
// FLAG / REPORT SOMETHING // FLAG / REPORT SOMETHING
return f.activityFlag(ctx, asType, receivingAccount, requestingAccount) return f.activityFlag(ctx, asType, receivingAccount, requestingAccount)
} }
return nil return nil
} }
@ -111,6 +112,7 @@ func (f *federatingDB) activityBlock(ctx context.Context, asType vocab.Type, rec
GTSModel: block, GTSModel: block,
ReceivingAccount: receiving, ReceivingAccount: receiving,
}) })
return nil return nil
} }
@ -132,37 +134,19 @@ func (f *federatingDB) activityCreate(
return gtserror.Newf("could not convert asType %T to ActivityStreamsCreate", asType) return gtserror.Newf("could not convert asType %T to ActivityStreamsCreate", asType)
} }
// Create must have an Object. for _, object := range ap.ExtractObjects(create) {
objectProp := create.GetActivityStreamsObject() // Try to get object as vocab.Type,
if objectProp == nil { // else skip handling (likely) IRI.
return gtserror.New("create had no Object") objType := object.GetType()
} if objType == nil {
// 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)
continue continue
} }
// Ensure given object type is a statusable. if statusable, ok := ap.ToStatusable(objType); ok {
statusable, ok := object.(ap.Statusable) return f.createStatusable(ctx, statusable, receivingAccount, requestingAccount)
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
} }
// Handle creation of statusable. // TODO: handle CREATE of other types?
return f.createStatusable(ctx,
statusable,
receivingAccount,
requestingAccount,
)
} }
return nil return nil

View file

@ -34,6 +34,7 @@ type DB interface {
Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error
Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error
Announce(ctx context.Context, announce vocab.ActivityStreamsAnnounce) 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. // FederatingDB uses the underlying DB interface to implement the go-fed pub.Database interface.

View file

@ -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 <http://www.gnu.org/licenses/>.
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)
}

View file

@ -27,6 +27,7 @@
"github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
) )
@ -48,31 +49,31 @@ func (f *federatingDB) Undo(ctx context.Context, undo vocab.ActivityStreamsUndo)
return nil // Already processed. return nil // Already processed.
} }
undoObject := undo.GetActivityStreamsObject() var errs gtserror.MultiError
if undoObject == nil {
return errors.New("UNDO: no object set on vocab.ActivityStreamsUndo")
}
for iter := undoObject.Begin(); iter != undoObject.End(); iter = iter.Next() { for _, object := range ap.ExtractObjects(undo) {
t := iter.GetType() // Try to get object as vocab.Type,
if t == nil { // else skip handling (likely) IRI.
objType := object.GetType()
if objType == nil {
continue continue
} }
switch t.GetTypeName() { switch objType.GetTypeName() {
case ap.ActivityFollow: case ap.ActivityFollow:
if err := f.undoFollow(ctx, receivingAccount, undo, t); err != nil { if err := f.undoFollow(ctx, receivingAccount, undo, objType); err != nil {
return err errs.Appendf("error undoing follow: %w", err)
} }
case ap.ActivityLike: case ap.ActivityLike:
if err := f.undoLike(ctx, receivingAccount, undo, t); err != nil { if err := f.undoLike(ctx, receivingAccount, undo, objType); err != nil {
return err errs.Appendf("error undoing like: %w", err)
} }
case ap.ActivityAnnounce: case ap.ActivityAnnounce:
// todo: undo boost / reblog / announce // TODO: actually handle this !
log.Warn(ctx, "skipped undo announce")
case ap.ActivityBlock: case ap.ActivityBlock:
if err := f.undoBlock(ctx, receivingAccount, undo, t); err != nil { if err := f.undoBlock(ctx, receivingAccount, undo, objType); err != nil {
return err errs.Appendf("error undoing block: %w", err)
} }
} }
} }

View file

@ -56,21 +56,18 @@ func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error {
return nil // Already processed. return nil // Already processed.
} }
switch asType.GetTypeName() { if accountable, ok := ap.ToAccountable(asType); ok {
case ap.ActorApplication, ap.ActorGroup, ap.ActorOrganization, ap.ActorPerson, ap.ActorService: return f.updateAccountable(ctx, receivingAccount, requestingAccount, accountable)
return f.updateAccountable(ctx, receivingAccount, requestingAccount, asType) }
if statusable, ok := ap.ToStatusable(asType); ok {
return f.updateStatusable(ctx, receivingAccount, requestingAccount, statusable)
} }
return nil return nil
} }
func (f *federatingDB) updateAccountable(ctx context.Context, receivingAcct *gtsmodel.Account, requestingAcct *gtsmodel.Account, asType vocab.Type) error { func (f *federatingDB) updateAccountable(ctx context.Context, receivingAcct *gtsmodel.Account, requestingAcct *gtsmodel.Account, accountable ap.Accountable) 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)
}
// Extract AP URI of the updated Accountable model. // Extract AP URI of the updated Accountable model.
idProp := accountable.GetJSONLDId() idProp := accountable.GetJSONLDId()
if idProp == nil || !idProp.IsIRI() { if idProp == nil || !idProp.IsIRI() {
@ -103,3 +100,43 @@ func (f *federatingDB) updateAccountable(ctx context.Context, receivingAcct *gts
return nil 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
}

View file

@ -522,6 +522,9 @@ func(ctx context.Context, reject vocab.ActivityStreamsReject) error {
func(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error { func(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error {
return f.FederatingDB().Announce(ctx, announce) return f.FederatingDB().Announce(ctx, announce)
}, },
func(ctx context.Context, question vocab.ActivityStreamsQuestion) error {
return f.FederatingDB().Question(ctx, question)
},
} }
return return

View file

@ -34,70 +34,75 @@
"github.com/superseriousbusiness/gotosocial/internal/text" "github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/typeutils" "github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/uris" "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. // 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. // 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) { func (p *Processor) Create(ctx context.Context, requestingAccount *gtsmodel.Account, application *gtsmodel.Application, form *apimodel.AdvancedStatusCreateForm) (*apimodel.Status, gtserror.WithCode) {
accountURIs := uris.GenerateURIsForAccount(account.Username) // Generate new ID for status.
thisStatusID := id.NewULID() statusID := id.NewULID()
local := true
sensitive := form.Sensitive
newStatus := &gtsmodel.Status{ // Generate necessary URIs for username, to build status URIs.
ID: thisStatusID, accountURIs := uris.GenerateURIsForAccount(requestingAccount.Username)
URI: accountURIs.StatusesURI + "/" + thisStatusID,
URL: accountURIs.StatusesURL + "/" + thisStatusID, // Get current time.
CreatedAt: time.Now(), now := time.Now()
UpdatedAt: time.Now(),
Local: &local, status := &gtsmodel.Status{
AccountID: account.ID, ID: statusID,
AccountURI: account.URI, URI: accountURIs.StatusesURI + "/" + statusID,
ContentWarning: text.SanitizeToPlaintext(form.SpoilerText), URL: accountURIs.StatusesURL + "/" + statusID,
CreatedAt: now,
UpdatedAt: now,
Local: util.Ptr(true),
Account: requestingAccount,
AccountID: requestingAccount.ID,
AccountURI: requestingAccount.URI,
ActivityStreamsType: ap.ObjectNote, ActivityStreamsType: ap.ObjectNote,
Sensitive: &sensitive, Sensitive: &form.Sensitive,
CreatedWithApplicationID: application.ID, CreatedWithApplicationID: application.ID,
Text: form.Status, 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 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 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) 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) 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) return nil, gtserror.NewErrorInternalError(err)
} }
// put the new status in the database // Insert this new status in the database.
if err := p.state.DB.PutStatus(ctx, newStatus); err != nil { if err := p.state.DB.PutStatus(ctx, status); err != nil {
return nil, gtserror.NewErrorInternalError(err) 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{ p.state.Workers.EnqueueClientAPI(ctx, messages.FromClientAPI{
APObjectType: ap.ObjectNote, APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityCreate, APActivityType: ap.ActivityCreate,
GTSModel: newStatus, GTSModel: status,
OriginAccount: account, 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 == "" { if form.InReplyToID == "" {
return nil 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? // 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. // If this is all OK, then we fetch the repliedStatus and the repliedAccount for later processing.
repliedStatus := &gtsmodel.Status{}
repliedAccount := &gtsmodel.Account{}
if err := dbService.GetByID(ctx, form.InReplyToID, repliedStatus); err != nil { inReplyTo, err := p.state.DB.GetStatusByID(ctx, form.InReplyToID)
if err == db.ErrNoEntries { if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := fmt.Errorf("status with id %s not replyable because it doesn't exist", form.InReplyToID) err := gtserror.Newf("error fetching status %s from db: %w", form.InReplyToID, err)
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)
return gtserror.NewErrorInternalError(err) return gtserror.NewErrorInternalError(err)
} }
if blocked, err := dbService.IsEitherBlocked(ctx, thisAccountID, repliedAccount.ID); err != nil { if inReplyTo == nil {
err := fmt.Errorf("db error checking block: %s", err) 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) return gtserror.NewErrorInternalError(err)
} else if blocked { } else if blocked {
err := fmt.Errorf("status with id %s not replyable", form.InReplyToID) text := fmt.Sprintf("status %s is not replyable", form.InReplyToID)
return gtserror.NewErrorNotFound(err) return gtserror.NewErrorNotFound(errors.New(text), text)
} }
status.InReplyToID = repliedStatus.ID // Set status fields from inReplyTo.
status.InReplyToURI = repliedStatus.URI status.InReplyToID = inReplyTo.ID
status.InReplyToAccountID = repliedAccount.ID status.InReplyToURI = inReplyTo.URI
status.InReplyToAccountID = inReplyTo.AccountID
return nil 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 { if form.MediaIDs == nil {
return nil return nil
} }
// Get minimum allowed char descriptions.
minChars := config.GetMediaDescriptionMinChars()
attachments := []*gtsmodel.MediaAttachment{} attachments := []*gtsmodel.MediaAttachment{}
attachmentIDs := []string{} attachmentIDs := []string{}
for _, mediaID := range form.MediaIDs { for _, mediaID := range form.MediaIDs {
attachment, err := dbService.GetAttachmentByID(ctx, mediaID) attachment, err := p.state.DB.GetAttachmentByID(ctx, mediaID)
if err != nil { if err != nil && !errors.Is(err, db.ErrNoEntries) {
if errors.Is(err, db.ErrNoEntries) { err := gtserror.Newf("error fetching media from db: %w", err)
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)
return gtserror.NewErrorInternalError(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 { if attachment.AccountID != thisAccountID {
err = fmt.Errorf("ProcessMediaIDs: media with id %s does not belong to account %s", mediaID, thisAccountID) text := fmt.Sprintf("media %s does not belong to account", mediaID)
return gtserror.NewErrorBadRequest(err, err.Error()) return gtserror.NewErrorBadRequest(errors.New(text), text)
} }
if attachment.StatusID != "" || attachment.ScheduledStatusID != "" { if attachment.StatusID != "" || attachment.ScheduledStatusID != "" {
err = fmt.Errorf("ProcessMediaIDs: media with id %s is already attached to a status", mediaID) text := fmt.Sprintf("media %s already attached to status", mediaID)
return gtserror.NewErrorBadRequest(err, err.Error()) return gtserror.NewErrorBadRequest(errors.New(text), text)
} }
minDescriptionChars := config.GetMediaDescriptionMinChars() if length := len([]rune(attachment.Description)); length < minChars {
if descriptionLength := len([]rune(attachment.Description)); descriptionLength < minDescriptionChars { text := fmt.Sprintf("media %s description too short, at least %d required", mediaID, minChars)
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(errors.New(text), text)
return gtserror.NewErrorBadRequest(err, err.Error())
} }
attachments = append(attachments, attachment) attachments = append(attachments, attachment)
@ -192,7 +193,7 @@ func processMediaIDs(ctx context.Context, dbService db.DB, form *apimodel.Advanc
return nil 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 // by default all flags are set to true
federated := true federated := true
boostable := true boostable := true
@ -265,7 +266,7 @@ func processVisibility(ctx context.Context, form *apimodel.AdvancedStatusCreateF
return nil 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 != "" { if form.Language != "" {
status.Language = form.Language status.Language = form.Language
} else { } else {
@ -277,68 +278,80 @@ func processLanguage(ctx context.Context, form *apimodel.AdvancedStatusCreateFor
return nil 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 { func (p *Processor) processContent(ctx context.Context, parseMention gtsmodel.ParseMentionFunc, form *apimodel.AdvancedStatusCreateForm, 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
if form.ContentType == "" { if form.ContentType == "" {
acct, err := dbService.GetAccountByID(ctx, accountID) // If content type wasn't specified, use the author's preferred content-type.
if err != nil { contentType := apimodel.StatusContentType(status.Account.StatusContentType)
return fmt.Errorf("error processing new content: couldn't retrieve account from db to check post format: %s", err) form.ContentType = contentType
} }
switch acct.StatusContentType { // format is the currently set text formatting
case "text/plain": // function, according to the provided content-type.
form.ContentType = apimodel.StatusContentTypePlain var format text.FormatFunc
case "text/markdown":
form.ContentType = apimodel.StatusContentTypeMarkdown // formatInput is a shorthand function to format the given input string with the
default: // currently set 'formatFunc', passing in all required args and returning result.
form.ContentType = apimodel.StatusContentTypeDefault 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 { switch form.ContentType {
// None given / set,
// use default (plain).
case "":
fallthrough
// Format status according to text/plain.
case apimodel.StatusContentTypePlain: case apimodel.StatusContentTypePlain:
f = formatter.FromPlain format = p.formatter.FromPlain
// Format status according to text/markdown.
case apimodel.StatusContentTypeMarkdown: case apimodel.StatusContentTypeMarkdown:
f = formatter.FromMarkdown format = p.formatter.FromMarkdown
// Unknown.
default: default:
return fmt.Errorf("format %s not recognised as a valid status format", form.ContentType) return fmt.Errorf("invalid status format: %q", 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)
} }
status.Tags = formatted.Tags // Sanitize status text and format.
status.TagIDs = make([]string, 0, len(formatted.Tags)) contentRes := formatInput(format, form.Status)
for _, gtstag := range formatted.Tags {
status.TagIDs = append(status.TagIDs, gtstag.ID)
}
status.Emojis = formatted.Emojis // Collect formatted results.
status.EmojiIDs = make([]string, 0, len(formatted.Emojis)) status.Content = contentRes.HTML
for _, gtsemoji := range formatted.Emojis { status.Mentions = append(status.Mentions, contentRes.Mentions...)
status.EmojiIDs = append(status.EmojiIDs, gtsemoji.ID) status.Emojis = append(status.Emojis, contentRes.Emojis...)
} status.Tags = append(status.Tags, contentRes.Tags...)
spoilerformatted := formatter.FromPlainEmojiOnly(ctx, parseMention, accountID, status.ID, form.SpoilerText) // From here-on-out just use emoji-only
for _, gtsemoji := range spoilerformatted.Emojis { // plain-text formatting as the FormatFunc.
status.Emojis = append(status.Emojis, gtsemoji) format = p.formatter.FromPlainEmojiOnly
status.EmojiIDs = append(status.EmojiIDs, gtsemoji.ID)
} // 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 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
}

View file

@ -204,7 +204,7 @@ func (suite *StatusCreateTestSuite) TestProcessMediaDescriptionTooShort() {
} }
apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm) 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) suite.Nil(apiStatus)
} }

View file

@ -46,7 +46,7 @@ func (p *Processor) toAccount(payload string, event string, streamTypes []string
if !ok { if !ok {
return nil // No entry = nothing to stream. return nil // No entry = nothing to stream.
} }
streamsForAccount := v.(*stream.StreamsForAccount) //nolint:forcetypeassert streamsForAccount := v.(*stream.StreamsForAccount)
streamsForAccount.Lock() streamsForAccount.Lock()
defer streamsForAccount.Unlock() defer streamsForAccount.Unlock()

View file

@ -147,27 +147,27 @@ func (f *federate) CreateStatus(ctx context.Context, status *gtsmodel.Status) er
return nil return nil
} }
// Populate model. // Ensure the status model is fully populated.
if err := f.state.DB.PopulateStatus(ctx, status); err != nil { if err := f.state.DB.PopulateStatus(ctx, status); err != nil {
return gtserror.Newf("error populating status: %w", err) 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) outboxIRI, err := parseURI(status.Account.OutboxURI)
if err != nil { if err != nil {
return err return err
} }
// Convert status to an ActivityStreams // Convert status to ActivityStreams Statusable implementing type.
// Note, wrapped in a Create activity. statusable, err := f.converter.StatusToAS(ctx, status)
asStatus, err := f.converter.StatusToAS(ctx, status)
if err != nil { 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 { 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. // 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 return nil
} }
// Populate model. // Ensure the status model is fully populated.
if err := f.state.DB.PopulateStatus(ctx, status); err != nil { if err := f.state.DB.PopulateStatus(ctx, status); err != nil {
return gtserror.Newf("error populating status: %w", err) 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) outboxIRI, err := parseURI(status.Account.OutboxURI)
if err != nil { if err != nil {
return err return err
@ -226,6 +226,50 @@ func (f *federate) DeleteStatus(ctx context.Context, status *gtsmodel.Status) er
return nil 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 { func (f *federate) Follow(ctx context.Context, follow *gtsmodel.Follow) error {
// Populate model. // Populate model.
if err := f.state.DB.PopulateFollow(ctx, follow); err != nil { if err := f.state.DB.PopulateFollow(ctx, follow); err != nil {

View file

@ -114,6 +114,10 @@ func (p *Processor) ProcessFromClientAPI(ctx context.Context, cMsg messages.From
case ap.ActivityUpdate: case ap.ActivityUpdate:
switch cMsg.APObjectType { switch cMsg.APObjectType {
// UPDATE NOTE/STATUS
case ap.ObjectNote:
return p.clientAPI.UpdateStatus(ctx, cMsg)
// UPDATE PROFILE/ACCOUNT // UPDATE PROFILE/ACCOUNT
case ap.ObjectProfile, ap.ActorPerson: case ap.ObjectProfile, ap.ActorPerson:
return p.clientAPI.UpdateAccount(ctx, cMsg) return p.clientAPI.UpdateAccount(ctx, cMsg)
@ -332,10 +336,25 @@ func (p *clientAPI) CreateBlock(ctx context.Context, cMsg messages.FromClientAPI
return nil 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 { func (p *clientAPI) UpdateAccount(ctx context.Context, cMsg messages.FromClientAPI) error {
account, ok := cMsg.GTSModel.(*gtsmodel.Account) account, ok := cMsg.GTSModel.(*gtsmodel.Account)
if !ok { 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 { if err := p.federate.UpdateAccount(ctx, account); err != nil {

View file

@ -119,6 +119,10 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg messages.FromFe
case ap.ActivityUpdate: case ap.ActivityUpdate:
switch fMsg.APObjectType { //nolint:gocritic switch fMsg.APObjectType { //nolint:gocritic
// UPDATE NOTE/STATUS
case ap.ObjectNote:
return p.fediAPI.UpdateStatus(ctx, fMsg)
// UPDATE PROFILE/ACCOUNT // UPDATE PROFILE/ACCOUNT
case ap.ObjectProfile: case ap.ObjectProfile:
return p.fediAPI.UpdateAccount(ctx, fMsg) 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. // Parse the old/existing account model.
account, ok := fMsg.GTSModel.(*gtsmodel.Account) account, ok := fMsg.GTSModel.(*gtsmodel.Account)
if !ok { 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. // Because this was an Update, the new Accountable should be set on the message.
apubAcc, ok := fMsg.APObjectModel.(ap.Accountable) apubAcc, ok := fMsg.APObjectModel.(ap.Accountable)
if !ok { 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. // Fetch up-to-date bio, avatar, header, etc.
@ -509,6 +513,34 @@ func (p *fediAPI) UpdateAccount(ctx context.Context, fMsg messages.FromFediAPI)
return nil 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 { func (p *fediAPI) DeleteStatus(ctx context.Context, fMsg messages.FromFediAPI) error {
// Delete attachments from this status, since this request // Delete attachments from this status, since this request
// comes from the federating API, and there's no way the // comes from the federating API, and there's no way the

View file

@ -38,7 +38,7 @@ func wipeStatusF(state *state.State, media *media.Processor, surface *surface) w
statusToDelete *gtsmodel.Status, statusToDelete *gtsmodel.Status,
deleteAttachments bool, deleteAttachments bool,
) error { ) error {
errs := new(gtserror.MultiError) var errs gtserror.MultiError
// Either delete all attachments for this status, // Either delete all attachments for this status,
// or simply unattach + clean them separately later. // 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) // status immediately (in case of delete + redraft)
if deleteAttachments { if deleteAttachments {
// todo:state.DB.DeleteAttachmentsForStatus // todo:state.DB.DeleteAttachmentsForStatus
for _, a := range statusToDelete.AttachmentIDs { for _, id := range statusToDelete.AttachmentIDs {
if err := media.Delete(ctx, a); err != nil { if err := media.Delete(ctx, id); err != nil {
errs.Appendf("error deleting media: %w", err) errs.Appendf("error deleting media: %w", err)
} }
} }
} else { } else {
// todo:state.DB.UnattachAttachmentsForStatus // todo:state.DB.UnattachAttachmentsForStatus
for _, a := range statusToDelete.AttachmentIDs { for _, id := range statusToDelete.AttachmentIDs {
if _, err := media.Unattach(ctx, statusToDelete.Account, a); err != nil { if _, err := media.Unattach(ctx, statusToDelete.Account, id); err != nil {
errs.Appendf("error unattaching media: %w", err) 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 { if err != nil {
errs.Appendf("error fetching status boosts: %w", err) 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) 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) errs.Appendf("error deleting boost: %w", err)
} }
} }

View file

@ -21,6 +21,7 @@
"bytes" "bytes"
"context" "context"
"codeberg.org/gruf/go-byteutil"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
@ -103,11 +104,11 @@ func (f *Formatter) FromPlainEmojiOnly(
statusID string, statusID string,
input string, input string,
) *FormatResult { ) *FormatResult {
// Initialize standard block parser // Initialize block parser that
// that wraps result in <p> tags. // doesn't wrap result in <p> tags.
plainTextParser := parser.NewParser( plainTextParser := parser.NewParser(
parser.WithBlockParsers( 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. // Parse input into HTML.
var htmlBytes bytes.Buffer var htmlBytes bytes.Buffer
if err := md.Convert( if err := md.Convert(
[]byte(input), bInput,
&htmlBytes, &htmlBytes,
); err != nil { ); err != nil {
log.Errorf(ctx, "error formatting plaintext input to HTML: %s", err) log.Errorf(ctx, "error formatting plaintext input to HTML: %s", err)
} }
// Clean and shrink HTML. // Clean and shrink HTML.
result.HTML = htmlBytes.String() result.HTML = byteutil.B2S(htmlBytes.Bytes())
result.HTML = SanitizeToHTML(result.HTML) result.HTML = SanitizeToHTML(result.HTML)
result.HTML = MinifyHTML(result.HTML) result.HTML = MinifyHTML(result.HTML)

View file

@ -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 // a point where the items are out of the range
// we're interested in. // we're interested in.
rangeF = func(e *list.Element) (bool, error) { rangeF = func(e *list.Element) (bool, error) {
entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert entry := e.Value.(*indexedItemsEntry)
if entry.itemID >= behindID { if entry.itemID >= behindID {
// ID of this item is too high, // 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. // Move the mark back one place each loop.
beforeIDMark = e beforeIDMark = e
//nolint:forcetypeassert
if entry := e.Value.(*indexedItemsEntry); entry.itemID <= beforeID { if entry := e.Value.(*indexedItemsEntry); entry.itemID <= beforeID {
// We've gone as far as we can through // We've gone as far as we can through
// the list and reached entries that are // 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 // To preserve ordering, we need to reverse the slice
// when we're finished. // when we're finished.
for e := beforeIDMark; e != nil; e = e.Prev() { for e := beforeIDMark; e != nil; e = e.Prev() {
entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert entry := e.Value.(*indexedItemsEntry)
if entry.itemID == beforeID { if entry.itemID == beforeID {
// Don't include the beforeID // Don't include the beforeID

View file

@ -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() { for e := t.items.data.Front(); e != nil; e = e.Next() {
entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert entry := e.Value.(*indexedItemsEntry)
position++ 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. // Don't grab more than we need to.
amount-grabbed, amount-grabbed,
) )
if err != nil { if err != nil {
// Grab function already checks for // Grab function already checks for
// db.ErrNoEntries, so if an error // db.ErrNoEntries, so if an error
@ -280,5 +279,5 @@ func (t *timeline) OldestIndexedItemID() string {
return "" return ""
} }
return e.Value.(*indexedItemsEntry).itemID //nolint:forcetypeassert return e.Value.(*indexedItemsEntry).itemID
} }

View file

@ -65,7 +65,7 @@ func (i *indexedItems) insertIndexed(ctx context.Context, newEntry *indexedItems
for e := i.data.Front(); e != nil; e = e.Next() { for e := i.data.Front(); e != nil; e = e.Next() {
currentPosition++ currentPosition++
currentEntry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert currentEntry := e.Value.(*indexedItemsEntry)
// Check if we need to skip inserting this item based on // Check if we need to skip inserting this item based on
// the current item. // the current item.

View file

@ -219,7 +219,6 @@ func (m *manager) UnprepareItemFromAllTimelines(ctx context.Context, itemID stri
// Work through all timelines held by this // Work through all timelines held by this
// manager, and call Unprepare for each. // manager, and call Unprepare for each.
m.timelines.Range(func(_ any, v any) bool { m.timelines.Range(func(_ any, v any) bool {
// nolint:forcetypeassert
if err := v.(Timeline).Unprepare(ctx, itemID); err != nil { if err := v.(Timeline).Unprepare(ctx, itemID); err != nil {
errs.Append(err) errs.Append(err)
} }
@ -248,7 +247,7 @@ func (m *manager) getOrCreateTimeline(ctx context.Context, timelineID string) Ti
i, ok := m.timelines.Load(timelineID) i, ok := m.timelines.Load(timelineID)
if ok { if ok {
// Timeline already existed in sync.Map. // Timeline already existed in sync.Map.
return i.(Timeline) //nolint:forcetypeassert return i.(Timeline)
} }
// Timeline did not yet exist in sync.Map. // Timeline did not yet exist in sync.Map.

View file

@ -63,7 +63,7 @@ func (t *timeline) prepareXBetweenIDs(ctx context.Context, amount int, behindID
if frontToBack { if frontToBack {
// Paging forwards / down. // Paging forwards / down.
for e := t.items.data.Front(); e != nil; e = e.Next() { 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 { if entry.itemID > behindID {
l.Trace("item is too new, continuing") l.Trace("item is too new, continuing")
@ -91,7 +91,7 @@ func (t *timeline) prepareXBetweenIDs(ctx context.Context, amount int, behindID
} else { } else {
// Paging backwards / up. // Paging backwards / up.
for e := t.items.data.Back(); e != nil; e = e.Prev() { 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 { if entry.itemID < beforeID {
l.Trace("item is too old, continuing") l.Trace("item is too old, continuing")

View file

@ -63,7 +63,7 @@ func (t *timeline) Prune(desiredPreparedItemsLength int, desiredIndexedItemsLeng
continue continue
} }
entry := e.Value.(*indexedItemsEntry) //nolint:forcetypeassert entry := e.Value.(*indexedItemsEntry)
if entry.prepared == nil { if entry.prepared == nil {
// It's already unprepared (mood). // It's already unprepared (mood).
continue continue

View file

@ -42,7 +42,7 @@ func (t *timeline) Remove(ctx context.Context, statusID string) (int, error) {
var toRemove []*list.Element var toRemove []*list.Element
for e := t.items.data.Front(); e != nil; e = e.Next() { 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 { if entry.itemID != statusID {
// Not relevant. // Not relevant.
@ -78,7 +78,7 @@ func (t *timeline) RemoveAllByOrBoosting(ctx context.Context, accountID string)
var toRemove []*list.Element var toRemove []*list.Element
for e := t.items.data.Front(); e != nil; e = e.Next() { 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 { if entry.accountID != accountID && entry.boostOfAccountID != accountID {
// Not relevant. // Not relevant.

View file

@ -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() { 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 { if entry.itemID != itemID && entry.boostOfID != itemID {
// Not relevant. // Not relevant.

View file

@ -216,40 +216,10 @@ func (c *Converter) ASRepresentationToAccount(ctx context.Context, accountable a
return acct, nil 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. // 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) { func (c *Converter) ASStatusToStatus(ctx context.Context, statusable ap.Statusable) (*gtsmodel.Status, error) {
var err error
status := new(gtsmodel.Status) status := new(gtsmodel.Status)
// status.URI // status.URI
@ -281,7 +251,19 @@ func (c *Converter) ASStatusToStatus(ctx context.Context, statusable ap.Statusab
// status.Attachments // status.Attachments
// //
// Media attachments for later dereferencing. // 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 // status.Hashtags
// //
@ -341,7 +323,7 @@ func (c *Converter) ASStatusToStatus(ctx context.Context, statusable ap.Statusab
// error if we don't. // error if we don't.
attributedTo, err := ap.ExtractAttributedToURI(statusable) attributedTo, err := ap.ExtractAttributedToURI(statusable)
if err != nil { if err != nil {
return nil, gtserror.Newf("%w", err) return nil, gtserror.Newf("error extracting attributed to uri: %w", err)
} }
accountURI := attributedTo.String() accountURI := attributedTo.String()

View file

@ -29,6 +29,7 @@
"github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/activity/pub"
"github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/activity/streams"
"github.com/superseriousbusiness/activity/streams/vocab" "github.com/superseriousbusiness/activity/streams/vocab"
"github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
@ -403,21 +404,15 @@ func (c *Converter) AccountToASMinimal(ctx context.Context, a *gtsmodel.Account)
return person, nil return person, nil
} }
// StatusToAS converts a gts model status into an activity streams note, suitable for federation // StatusToAS converts a gts model status into an ActivityStreams Statusable implementation, suitable for federation
func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.ActivityStreamsNote, error) { func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (ap.Statusable, error) {
// ensure prerequisites here before we get stuck in // Ensure the status model is fully populated.
// The status and poll models are REQUIRED so nothing to do if this fails.
// check if author account is already attached to status and attach it if not if err := c.state.DB.PopulateStatus(ctx, s); err != nil {
// if we can't retrieve this, bail here already because we can't attribute the status to anyone return nil, gtserror.Newf("error populating status: %w", err)
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
} }
// create the Note! // We convert it as an AS Note.
status := streams.NewActivityStreamsNote() status := streams.NewActivityStreamsNote()
// id // id
@ -529,7 +524,6 @@ func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (vocab.A
} }
tagProp.AppendTootHashtag(asHashtag) tagProp.AppendTootHashtag(asHashtag)
} }
status.SetActivityStreamsTag(tagProp) status.SetActivityStreamsTag(tagProp)
// parse out some URIs we need here // parse out some URIs we need here
@ -1419,7 +1413,7 @@ func (c *Converter) StatusesToASOutboxPage(ctx context.Context, outboxID string,
return nil, err return nil, err
} }
create, err := c.WrapNoteInCreate(note, true) create, err := c.WrapStatusableInCreate(note, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -44,7 +44,6 @@ func (c *Converter) WrapPersonInUpdate(person vocab.ActivityStreamsPerson, origi
update.SetActivityStreamsActor(actorProp) update.SetActivityStreamsActor(actorProp)
// set the ID // set the ID
newID, err := id.NewRandomULID() newID, err := id.NewRandomULID()
if err != nil { if err != nil {
return nil, err return nil, err
@ -85,26 +84,29 @@ func (c *Converter) WrapPersonInUpdate(person vocab.ActivityStreamsPerson, origi
return update, nil 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, // 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, // 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. // 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() create := streams.NewActivityStreamsCreate()
// Object property // Object property
objectProp := streams.NewActivityStreamsObjectProperty() objectProp := streams.NewActivityStreamsObjectProperty()
if objectIRIOnly { if objectIRIOnly {
objectProp.AppendIRI(note.GetJSONLDId().GetIRI()) // Only append the object IRI to objectProp.
objectProp.AppendIRI(status.GetJSONLDId().GetIRI())
} else { } else {
objectProp.AppendActivityStreamsNote(note) // Our statusable's are always note types.
asNote := status.(vocab.ActivityStreamsNote)
objectProp.AppendActivityStreamsNote(asNote)
} }
create.SetActivityStreamsObject(objectProp) create.SetActivityStreamsObject(objectProp)
// ID property // ID property
idProp := streams.NewJSONLDIdProperty() idProp := streams.NewJSONLDIdProperty()
createID := note.GetJSONLDId().GetIRI().String() + "/activity" createID := status.GetJSONLDId().GetIRI().String() + "/activity"
createIDIRI, err := url.Parse(createID) createIDIRI, err := url.Parse(createID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -114,7 +116,7 @@ func (c *Converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOn
// Actor Property // Actor Property
actorProp := streams.NewActivityStreamsActorProperty() actorProp := streams.NewActivityStreamsActorProperty()
actorIRI, err := ap.ExtractAttributedToURI(note) actorIRI, err := ap.ExtractAttributedToURI(status)
if err != nil { if err != nil {
return nil, gtserror.Newf("couldn't extract AttributedTo: %w", err) return nil, gtserror.Newf("couldn't extract AttributedTo: %w", err)
} }
@ -123,7 +125,7 @@ func (c *Converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOn
// Published Property // Published Property
publishedProp := streams.NewActivityStreamsPublishedProperty() publishedProp := streams.NewActivityStreamsPublishedProperty()
published, err := ap.ExtractPublished(note) published, err := ap.ExtractPublished(status)
if err != nil { if err != nil {
return nil, gtserror.Newf("couldn't extract Published: %w", err) return nil, gtserror.Newf("couldn't extract Published: %w", err)
} }
@ -132,7 +134,7 @@ func (c *Converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOn
// To Property // To Property
toProp := streams.NewActivityStreamsToProperty() toProp := streams.NewActivityStreamsToProperty()
if toURIs := ap.ExtractToURIs(note); len(toURIs) != 0 { if toURIs := ap.ExtractToURIs(status); len(toURIs) != 0 {
for _, toURI := range toURIs { for _, toURI := range toURIs {
toProp.AppendIRI(toURI) toProp.AppendIRI(toURI)
} }
@ -141,7 +143,7 @@ func (c *Converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOn
// Cc Property // Cc Property
ccProp := streams.NewActivityStreamsCcProperty() ccProp := streams.NewActivityStreamsCcProperty()
if ccURIs := ap.ExtractCcURIs(note); len(ccURIs) != 0 { if ccURIs := ap.ExtractCcURIs(status); len(ccURIs) != 0 {
for _, ccURI := range ccURIs { for _, ccURI := range ccURIs {
ccProp.AppendIRI(ccURI) ccProp.AppendIRI(ccURI)
} }
@ -150,3 +152,64 @@ func (c *Converter) WrapNoteInCreate(note vocab.ActivityStreamsNote, objectIRIOn
return create, nil 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
}

View file

@ -36,7 +36,7 @@ func (suite *WrapTestSuite) TestWrapNoteInCreateIRIOnly() {
note, err := suite.typeconverter.StatusToAS(context.Background(), testStatus) note, err := suite.typeconverter.StatusToAS(context.Background(), testStatus)
suite.NoError(err) suite.NoError(err)
create, err := suite.typeconverter.WrapNoteInCreate(note, true) create, err := suite.typeconverter.WrapStatusableInCreate(note, true)
suite.NoError(err) suite.NoError(err)
suite.NotNil(create) suite.NotNil(create)
@ -64,7 +64,7 @@ func (suite *WrapTestSuite) TestWrapNoteInCreate() {
note, err := suite.typeconverter.StatusToAS(context.Background(), testStatus) note, err := suite.typeconverter.StatusToAS(context.Background(), testStatus)
suite.NoError(err) suite.NoError(err)
create, err := suite.typeconverter.WrapNoteInCreate(note, false) create, err := suite.typeconverter.WrapStatusableInCreate(note, false)
suite.NoError(err) suite.NoError(err)
suite.NotNil(create) suite.NotNil(create)