[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:
# enable some extra linters, see here for the list: https://golangci-lint.run/usage/linters/
enable:
- forcetypeassert
- goconst
- gocritic
- gofmt

View file

@ -32,10 +32,10 @@
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
}

View file

@ -35,39 +35,56 @@
"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:

View file

@ -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

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.
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
}
}
}

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 {
failures := make(map[string]any, multiStatus.Metadata.Failure)
for _, entry := range multiStatus.Data {
// nolint:forcetypeassert
failures[entry.Resource.(string)] = entry.Message
}

View file

@ -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.

View file

@ -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 = &gtsmodel.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 {

View file

@ -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

View file

@ -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

View file

@ -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.

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/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)
}
}
}

View file

@ -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
}

View file

@ -522,6 +522,9 @@ func(ctx context.Context, reject vocab.ActivityStreamsReject) error {
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

View file

@ -34,70 +34,75 @@
"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 := &gtsmodel.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 := &gtsmodel.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 := &gtsmodel.Status{}
repliedAccount := &gtsmodel.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
}

View file

@ -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)
}

View file

@ -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()

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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)
}
}

View file

@ -21,6 +21,7 @@
"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 <p> tags.
// Initialize block parser that
// doesn't wrap result in <p> 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)

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
// 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

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() {
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
}

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() {
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.

View file

@ -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.

View file

@ -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")

View file

@ -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

View file

@ -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.

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() {
entry := e.Value.(*indexedItemsEntry) // nolint:forcetypeassert
entry := e.Value.(*indexedItemsEntry)
if entry.itemID != itemID && entry.boostOfID != itemID {
// Not relevant.

View file

@ -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()

View file

@ -29,6 +29,7 @@
"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
}

View file

@ -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
}

View file

@ -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)