diff options
Diffstat (limited to 'internal/federation')
| -rw-r--r-- | internal/federation/dereferencing/announce.go | 7 | ||||
| -rw-r--r-- | internal/federation/dereferencing/media.go | 1 | ||||
| -rw-r--r-- | internal/federation/dereferencing/status.go | 605 | ||||
| -rw-r--r-- | internal/federation/dereferencing/status_permitted.go | 10 | ||||
| -rw-r--r-- | internal/federation/dereferencing/status_test.go | 220 | ||||
| -rw-r--r-- | internal/federation/dereferencing/util.go | 6 | ||||
| -rw-r--r-- | internal/federation/federatingdb/announce_test.go | 2 | 
7 files changed, 668 insertions, 183 deletions
| diff --git a/internal/federation/dereferencing/announce.go b/internal/federation/dereferencing/announce.go index a3eaf199d..eb949f159 100644 --- a/internal/federation/dereferencing/announce.go +++ b/internal/federation/dereferencing/announce.go @@ -87,7 +87,7 @@ func (d *Dereferencer) EnrichAnnounce(  	boost.Federated = target.Federated  	// Ensure this Announce is permitted by the Announcee. -	permit, err := d.isPermittedStatus(ctx, requestUser, nil, boost) +	permit, err := d.isPermittedStatus(ctx, requestUser, nil, boost, true)  	if err != nil {  		return nil, gtserror.Newf("error checking permitted status %s: %w", boost.URI, err)  	} @@ -99,10 +99,7 @@ func (d *Dereferencer) EnrichAnnounce(  	}  	// Generate an ID for the boost wrapper status. -	boost.ID, err = id.NewULIDFromTime(boost.CreatedAt) -	if err != nil { -		return nil, gtserror.Newf("error generating id: %w", err) -	} +	boost.ID = id.NewULIDFromTime(boost.CreatedAt)  	// Store the boost wrapper status in database.  	switch err = d.state.DB.PutStatus(ctx, boost); { diff --git a/internal/federation/dereferencing/media.go b/internal/federation/dereferencing/media.go index 3bed4b198..d22eeb237 100644 --- a/internal/federation/dereferencing/media.go +++ b/internal/federation/dereferencing/media.go @@ -128,6 +128,7 @@ func (d *Dereferencer) RefreshMedia(  	// Check emoji is up-to-date  	// with provided extra info.  	switch { +	case force:  	case info.Blurhash != nil &&  		*info.Blurhash != attach.Blurhash:  		attach.Blurhash = *info.Blurhash diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index c90730826..d19669891 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -302,6 +302,7 @@ func (d *Dereferencer) enrichStatusSafely(  		uri,  		status,  		statusable, +		isNew,  	)  	// Check for a returned HTTP code via error. @@ -374,6 +375,7 @@ func (d *Dereferencer) enrichStatus(  	uri *url.URL,  	status *gtsmodel.Status,  	statusable ap.Statusable, +	isNew bool,  ) (  	*gtsmodel.Status,  	ap.Statusable, @@ -476,8 +478,7 @@ func (d *Dereferencer) enrichStatus(  	// Ensure the final parsed status URI or URL matches  	// the input URI we fetched (or received) it as. -	matches, err := util.URIMatches( -		uri, +	matches, err := util.URIMatches(uri,  		append(  			ap.GetURL(statusable),      // status URL(s)  			ap.GetJSONLDId(statusable), // status URI @@ -497,19 +498,10 @@ func (d *Dereferencer) enrichStatus(  		)  	} -	var isNew bool - -	// Based on the original provided -	// status model, determine whether -	// this is a new insert / update. -	if isNew = (status.ID == ""); isNew { +	if isNew {  		// Generate new status ID from the provided creation date. -		latestStatus.ID, err = id.NewULIDFromTime(latestStatus.CreatedAt) -		if err != nil { -			log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err) -			latestStatus.ID = id.NewULID() // just use "now" -		} +		latestStatus.ID = id.NewULIDFromTime(latestStatus.CreatedAt)  	} else {  		// Reuse existing status ID. @@ -519,7 +511,6 @@ func (d *Dereferencer) enrichStatus(  	// Set latest fetch time and carry-  	// over some values from "old" status.  	latestStatus.FetchedAt = time.Now() -	latestStatus.UpdatedAt = status.UpdatedAt  	latestStatus.Local = status.Local  	latestStatus.PinnedAt = status.PinnedAt @@ -538,8 +529,9 @@ func (d *Dereferencer) enrichStatus(  	}  	// Check if this is a permitted status we should accept. -	// Function also sets "PendingApproval" bool as necessary. -	permit, err := d.isPermittedStatus(ctx, requestUser, status, latestStatus) +	// Function also sets "PendingApproval" bool as necessary, +	// and handles removal of existing statuses no longer permitted. +	permit, err := d.isPermittedStatus(ctx, requestUser, status, latestStatus, isNew)  	if err != nil {  		return nil, nil, gtserror.Newf("error checking permissibility for status %s: %w", uri, err)  	} @@ -550,59 +542,113 @@ func (d *Dereferencer) enrichStatus(  		return nil, nil, gtserror.SetNotPermitted(err)  	} -	// Ensure the status' mentions are populated, and pass in existing to check for changes. -	if err := d.fetchStatusMentions(ctx, requestUser, status, latestStatus); err != nil { -		return nil, nil, gtserror.Newf("error populating mentions for status %s: %w", uri, err) +	// Insert / update any attached status poll. +	pollChanged, err := d.handleStatusPoll(ctx, +		status, +		latestStatus, +	) +	if err != nil { +		return nil, nil, gtserror.Newf("error handling poll for status %s: %w", uri, err)  	} -	// Ensure the status' poll remains consistent, else reset the poll. -	if err := d.fetchStatusPoll(ctx, status, latestStatus); err != nil { -		return nil, nil, gtserror.Newf("error populating poll for status %s: %w", uri, err) +	// Populate mentions associated with status, passing +	// in existing status to reuse old where possible. +	// (especially important here to reduce need to dereference). +	mentionsChanged, err := d.fetchStatusMentions(ctx, +		requestUser, +		status, +		latestStatus, +	) +	if err != nil { +		return nil, nil, gtserror.Newf("error populating mentions for status %s: %w", uri, err)  	} -	// Now that we know who this status replies to (handled by ASStatusToStatus) -	// and who it mentions, we can add a ThreadID to it if necessary. -	if err := d.threadStatus(ctx, latestStatus); err != nil { -		return nil, nil, gtserror.Newf("error checking / creating threadID for status %s: %w", uri, err) +	// Ensure status in a thread is connected. +	threadChanged, err := d.threadStatus(ctx, +		status, +		latestStatus, +	) +	if err != nil { +		return nil, nil, gtserror.Newf("error handling threading for status %s: %w", uri, err)  	} -	// Ensure the status' tags are populated, (changes are expected / okay). -	if err := d.fetchStatusTags(ctx, status, latestStatus); err != nil { +	// Populate tags associated with status, passing +	// in existing status to reuse old where possible. +	tagsChanged, err := d.fetchStatusTags(ctx, +		status, +		latestStatus, +	) +	if err != nil {  		return nil, nil, gtserror.Newf("error populating tags for status %s: %w", uri, err)  	} -	// Ensure the status' media attachments are populated, passing in existing to check for changes. -	if err := d.fetchStatusAttachments(ctx, requestUser, status, latestStatus); err != nil { +	// Populate media attachments associated with status, +	// passing in existing status to reuse old where possible +	// (especially important here to reduce need to dereference). +	mediaChanged, err := d.fetchStatusAttachments(ctx, +		requestUser, +		status, +		latestStatus, +	) +	if err != nil {  		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, status, latestStatus); err != nil { +	// Populate emoji associated with status, passing +	// in existing status to reuse old where possible +	// (especially important here to reduce need to dereference). +	emojiChanged, err := d.fetchStatusEmojis(ctx, +		status, +		latestStatus, +	) +	if err != nil {  		return nil, nil, gtserror.Newf("error populating emojis for status %s: %w", uri, err)  	}  	if isNew { -		// This is new, put the status in the database. -		err := d.state.DB.PutStatus(ctx, latestStatus) -		if err != nil { -			return nil, nil, gtserror.Newf("error putting in database: %w", err) +		// Simplest case, insert this new status into the database. +		if err := d.state.DB.PutStatus(ctx, latestStatus); err != nil { +			return nil, nil, gtserror.Newf("error inserting new status %s: %w", uri, err)  		}  	} else { -		// This is an existing status, update the model in the database. -		if err := d.state.DB.UpdateStatus(ctx, latestStatus); err != nil { -			return nil, nil, gtserror.Newf("error updating database: %w", err) +		// Check for and handle any edits to status, inserting +		// historical edit if necessary. Also determines status +		// columns that need updating in below query. +		cols, err := d.handleStatusEdit(ctx, +			status, +			latestStatus, +			pollChanged, +			mentionsChanged, +			threadChanged, +			tagsChanged, +			mediaChanged, +			emojiChanged, +		) +		if err != nil { +			return nil, nil, gtserror.Newf("error handling edit for status %s: %w", uri, err) +		} + +		// With returned changed columns, now update the existing status entry. +		if err := d.state.DB.UpdateStatus(ctx, latestStatus, cols...); err != nil { +			return nil, nil, gtserror.Newf("error updating existing status %s: %w", uri, err)  		}  	}  	return latestStatus, statusable, nil  } +// fetchStatusMentions populates the mentions on 'status', creating +// new where needed, or using unchanged mentions from 'existing' status.  func (d *Dereferencer) fetchStatusMentions(  	ctx context.Context,  	requestUser string,  	existing *gtsmodel.Status,  	status *gtsmodel.Status, -) error { +) ( +	changed bool, +	err error, +) { +  	// Allocate new slice to take the yet-to-be created mention IDs.  	status.MentionIDs = make([]string, len(status.Mentions)) @@ -610,7 +656,6 @@ func (d *Dereferencer) fetchStatusMentions(  		var (  			mention       = status.Mentions[i]  			alreadyExists bool -			err           error  		)  		// Search existing status for a mention already stored, @@ -633,19 +678,16 @@ func (d *Dereferencer) fetchStatusMentions(  			continue  		} +		// Mark status as +		// having changed. +		changed = true +  		// This mention didn't exist yet. -		// 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 (falling back to 'now'): %v", err) -			mention.ID = id.NewULID() // just use "now" -		} +		// Generate new ID according to latest update. +		mention.ID = id.NewULIDFromTime(status.UpdatedAt)  		// Set known further mention details. -		mention.CreatedAt = status.CreatedAt -		mention.UpdatedAt = status.UpdatedAt +		mention.CreatedAt = status.UpdatedAt  		mention.OriginAccount = status.Account  		mention.OriginAccountID = status.AccountID  		mention.OriginAccountURI = status.AccountURI @@ -657,7 +699,7 @@ func (d *Dereferencer) fetchStatusMentions(  		// Place the new mention into the database.  		if err := d.state.DB.PutMention(ctx, mention); err != nil { -			return gtserror.Newf("error putting mention in database: %w", err) +			return changed, gtserror.Newf("error putting mention in database: %w", err)  		}  		// Set the *new* mention and ID. @@ -678,17 +720,42 @@ func (d *Dereferencer) fetchStatusMentions(  		i++  	} -	return nil +	return changed, nil  } -func (d *Dereferencer) threadStatus(ctx context.Context, status *gtsmodel.Status) error { -	if status.InReplyTo != nil { -		if parentThreadID := status.InReplyTo.ThreadID; parentThreadID != "" { -			// Simplest case: parent status -			// is threaded, so inherit threadID. -			status.ThreadID = parentThreadID -			return nil +// threadStatus ensures that given status is threaded correctly +// where necessary. that is it will inherit a thread ID from the +// existing copy if it is threaded correctly, else it will inherit +// a thread ID from a parent with existing thread, else it will +// generate a new thread ID if status mentions a local account. +func (d *Dereferencer) threadStatus( +	ctx context.Context, +	existing *gtsmodel.Status, +	status *gtsmodel.Status, +) ( +	changed bool, +	err error, +) { + +	// Check for existing status +	// that is already threaded. +	if existing.ThreadID != "" { + +		// Existing is threaded correctly. +		if existing.InReplyTo == nil || +			existing.InReplyTo.ThreadID == existing.ThreadID { +			status.ThreadID = existing.ThreadID +			return false, nil  		} + +		// TODO: delete incorrect thread +	} + +	// Check for existing parent to inherit threading from. +	if inReplyTo := status.InReplyTo; inReplyTo != nil && +		inReplyTo.ThreadID != "" { +		status.ThreadID = inReplyTo.ThreadID +		return true, nil  	}  	// Parent wasn't threaded. If this @@ -711,7 +778,7 @@ func (d *Dereferencer) threadStatus(ctx context.Context, status *gtsmodel.Status  		// Status doesn't mention a  		// local account, so we don't  		// need to thread it. -		return nil +		return false, nil  	}  	// Status mentions a local account. @@ -719,24 +786,30 @@ func (d *Dereferencer) threadStatus(ctx context.Context, status *gtsmodel.Status  	// it to the status.  	threadID := id.NewULID() -	if err := d.state.DB.PutThread( -		ctx, -		>smodel.Thread{ -			ID: threadID, -		}, +	// Insert new thread model into db. +	if err := d.state.DB.PutThread(ctx, +		>smodel.Thread{ID: threadID},  	); err != nil { -		return gtserror.Newf("error inserting new thread in db: %w", err) +		return false, gtserror.Newf("error inserting new thread in db: %w", err)  	} +	// Set thread on latest status.  	status.ThreadID = threadID -	return nil +	return true, nil  } +// fetchStatusTags populates the tags on 'status', fetching existing +// from the database and creating new where needed. 'existing' is used +// to fetch tags that have not changed since previous stored status.  func (d *Dereferencer) fetchStatusTags(  	ctx context.Context,  	existing *gtsmodel.Status,  	status *gtsmodel.Status, -) error { +) ( +	changed bool, +	err error, +) { +  	// Allocate new slice to take the yet-to-be determined tag IDs.  	status.TagIDs = make([]string, len(status.Tags)) @@ -751,10 +824,14 @@ func (d *Dereferencer) fetchStatusTags(  			continue  		} +		// Mark status as +		// having changed. +		changed = true +  		// Look for existing tag with name in the database.  		existing, err := d.state.DB.GetTagByName(ctx, tag.Name)  		if err != nil && !errors.Is(err, db.ErrNoEntries) { -			return gtserror.Newf("db error getting tag %s: %w", tag.Name, err) +			return changed, gtserror.Newf("db error getting tag %s: %w", tag.Name, err)  		} else if existing != nil {  			status.Tags[i] = existing  			status.TagIDs[i] = existing.ID @@ -788,106 +865,21 @@ func (d *Dereferencer) fetchStatusTags(  		i++  	} -	return nil -} - -func (d *Dereferencer) fetchStatusPoll( -	ctx context.Context, -	existing *gtsmodel.Status, -	status *gtsmodel.Status, -) error { -	var ( -		// insertStatusPoll generates ID and inserts the poll attached to status into the database. -		insertStatusPoll = func(ctx context.Context, status *gtsmodel.Status) error { -			var err error - -			// Generate new ID for poll from the status CreatedAt. -			// TODO: update this to use "edited_at" when we add -			//       support for edited status revision history. -			status.Poll.ID, err = id.NewULIDFromTime(status.CreatedAt) -			if err != nil { -				log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err) -				status.Poll.ID = id.NewULID() // just use "now" -			} - -			// Update the status<->poll links. -			status.PollID = status.Poll.ID -			status.Poll.StatusID = status.ID -			status.Poll.Status = status - -			// Insert this latest poll into the database. -			err = d.state.DB.PutPoll(ctx, status.Poll) -			if err != nil { -				return gtserror.Newf("error putting in database: %w", err) -			} - -			return nil -		} - -		// deleteStatusPoll deletes the poll with ID, and all attached votes, from the database. -		deleteStatusPoll = func(ctx context.Context, pollID string) error { -			if err := d.state.DB.DeletePollByID(ctx, pollID); err != nil { -				return gtserror.Newf("error deleting existing poll from database: %w", err) -			} -			return nil -		} -	) - -	switch { -	case existing.Poll == nil && status.Poll == nil: -		// no poll before or after, nothing to do. -		return nil - -	case existing.Poll == nil && status.Poll != nil: -		// no previous poll, insert new poll! -		return insertStatusPoll(ctx, status) - -	case status.Poll == nil: -		// existing poll has been deleted, remove this. -		return deleteStatusPoll(ctx, existing.PollID) - -	case pollChanged(existing.Poll, status.Poll): -		// poll has changed since original, delete and reinsert new. -		if err := deleteStatusPoll(ctx, existing.PollID); err != nil { -			return err -		} -		return insertStatusPoll(ctx, status) - -	case pollUpdated(existing.Poll, status.Poll): -		// Since we last saw it, the poll has updated! -		// Whether that be stats, or close time. -		poll := existing.Poll -		poll.Closing = pollJustClosed(existing.Poll, status.Poll) -		poll.ClosedAt = status.Poll.ClosedAt -		poll.Voters = status.Poll.Voters -		poll.Votes = status.Poll.Votes - -		// Update poll model in the database (specifically only the possible changed columns). -		if err := d.state.DB.UpdatePoll(ctx, poll, "closed_at", "voters", "votes"); err != nil { -			return gtserror.Newf("error updating poll: %w", err) -		} - -		// Update poll on status. -		status.PollID = poll.ID -		status.Poll = poll -		return nil - -	default: -		// latest and existing -		// polls are up to date. -		poll := existing.Poll -		status.PollID = poll.ID -		status.Poll = poll -		return nil -	} +	return changed, nil  } +// fetchStatusAttachments populates the attachments on 'status', creating new database +// entries where needed and dereferencing it, or using unchanged from 'existing' status.  func (d *Dereferencer) fetchStatusAttachments(  	ctx context.Context,  	requestUser string,  	existing *gtsmodel.Status,  	status *gtsmodel.Status, -) error { +) ( +	changed bool, +	err error, +) { +  	// Allocate new slice to take the yet-to-be fetched attachment IDs.  	status.AttachmentIDs = make([]string, len(status.Attachments)) @@ -897,9 +889,26 @@ func (d *Dereferencer) fetchStatusAttachments(  		// Look for existing media attachment with remote URL first.  		existing, ok := existing.GetAttachmentByRemoteURL(placeholder.RemoteURL)  		if ok && existing.ID != "" { +			var info media.AdditionalMediaInfo -			// Ensure the existing media attachment is up-to-date and cached. -			existing, err := d.updateAttachment(ctx, requestUser, existing, placeholder) +			// Look for any difference in stored media description. +			diff := (existing.Description != placeholder.Description) +			if diff { +				info.Description = &placeholder.Description +			} + +			// If description changed, +			// we mark media as changed. +			changed = changed || diff + +			// Store any attachment updates and +			// ensure media is locally cached. +			existing, err := d.RefreshMedia(ctx, +				requestUser, +				existing, +				info, +				diff, +			)  			if err != nil {  				log.Errorf(ctx, "error updating existing attachment: %v", err) @@ -915,9 +924,12 @@ func (d *Dereferencer) fetchStatusAttachments(  			continue  		} +		// Mark status as +		// having changed. +		changed = true +  		// Load this new media attachment. -		attachment, err := d.GetMedia( -			ctx, +		attachment, err := d.GetMedia(ctx,  			requestUser,  			status.AccountID,  			placeholder.RemoteURL, @@ -955,28 +967,34 @@ func (d *Dereferencer) fetchStatusAttachments(  		i++  	} -	return nil +	return changed, nil  } +// fetchStatusEmojis populates the emojis on 'status', creating new database entries +// where needed and dereferencing it, or using unchanged from 'existing' status.  func (d *Dereferencer) fetchStatusEmojis(  	ctx context.Context,  	existing *gtsmodel.Status,  	status *gtsmodel.Status, -) error { +) ( +	changed bool, +	err error, +) { +  	// Fetch the updated emojis for our status.  	emojis, changed, err := d.fetchEmojis(ctx,  		existing.Emojis,  		status.Emojis,  	)  	if err != nil { -		return gtserror.Newf("error fetching emojis: %w", err) +		return changed, gtserror.Newf("error fetching emojis: %w", err)  	}  	if !changed {  		// Use existing status emoji objects.  		status.EmojiIDs = existing.EmojiIDs  		status.Emojis = existing.Emojis -		return nil +		return false, nil  	}  	// Set latest emojis. @@ -988,9 +1006,254 @@ func (d *Dereferencer) fetchStatusEmojis(  		status.EmojiIDs[i] = emoji.ID  	} +	return true, nil +} + +// handleStatusPoll handles both inserting of new status poll or the +// update of an existing poll. this handles the case of simple vote +// count updates (without being classified as a change of the poll +// itself), as well as full poll changes that delete existing instance. +func (d *Dereferencer) handleStatusPoll( +	ctx context.Context, +	existing *gtsmodel.Status, +	status *gtsmodel.Status, +) ( +	changed bool, +	err error, +) { +	switch { +	case existing.Poll == nil && status.Poll == nil: +		// no poll before or after, nothing to do. +		return false, nil + +	case existing.Poll == nil && status.Poll != nil: +		// no previous poll, insert new status poll! +		return true, d.insertStatusPoll(ctx, status) + +	case status.Poll == nil: +		// existing status poll has been deleted, remove this from the database. +		if err = d.state.DB.DeletePollByID(ctx, existing.Poll.ID); err != nil { +			err = gtserror.Newf("error deleting poll from database: %w", err) +		} +		return true, err + +	case pollChanged(existing.Poll, status.Poll): +		// existing status poll has been changed, remove this from the database. +		if err = d.state.DB.DeletePollByID(ctx, existing.Poll.ID); err != nil { +			return true, gtserror.Newf("error deleting poll from database: %w", err) +		} + +		// insert latest poll version into database. +		return true, d.insertStatusPoll(ctx, status) + +	case pollStateUpdated(existing.Poll, status.Poll): +		// Since we last saw it, the poll has updated! +		// Whether that be stats, or close time. +		poll := existing.Poll +		poll.Closing = pollJustClosed(existing.Poll, status.Poll) +		poll.ClosedAt = status.Poll.ClosedAt +		poll.Voters = status.Poll.Voters +		poll.Votes = status.Poll.Votes + +		// Update poll model in the database (specifically only the possible changed columns). +		if err = d.state.DB.UpdatePoll(ctx, poll, "closed_at", "voters", "votes"); err != nil { +			return false, gtserror.Newf("error updating poll: %w", err) +		} + +		// Update poll on status. +		status.PollID = poll.ID +		status.Poll = poll +		return false, nil + +	default: +		// latest and existing +		// polls are up to date. +		poll := existing.Poll +		status.PollID = poll.ID +		status.Poll = poll +		return false, nil +	} +} + +// insertStatusPoll inserts an assumed new poll attached to status into the database, this +// also handles generating new ID for the poll and setting necessary fields on the status. +func (d *Dereferencer) insertStatusPoll(ctx context.Context, status *gtsmodel.Status) error { +	var err error + +	// Generate new ID for poll from latest updated time. +	status.Poll.ID = id.NewULIDFromTime(status.UpdatedAt) + +	// Update the status<->poll links. +	status.PollID = status.Poll.ID +	status.Poll.StatusID = status.ID +	status.Poll.Status = status + +	// Insert this latest poll into the database. +	err = d.state.DB.PutPoll(ctx, status.Poll) +	if err != nil { +		return gtserror.Newf("error putting poll in database: %w", err) +	} +  	return nil  } +// handleStatusEdit compiles a list of changed status table columns between +// existing and latest status model, and where necessary inserts a historic +// edit of the status into the database to store its previous state. the +// returned slice is a list of columns requiring updating in the database. +func (d *Dereferencer) handleStatusEdit( +	ctx context.Context, +	existing *gtsmodel.Status, +	status *gtsmodel.Status, +	pollChanged bool, +	mentionsChanged bool, +	threadChanged bool, +	tagsChanged bool, +	mediaChanged bool, +	emojiChanged bool, +) ( +	cols []string, +	err error, +) { +	var edited bool + +	// Preallocate max slice length. +	cols = make([]string, 0, 13) + +	// Always update `fetched_at`. +	cols = append(cols, "fetched_at") + +	// Check for edited status content. +	if existing.Content != status.Content { +		cols = append(cols, "content") +		edited = true +	} + +	// Check for edited status content warning. +	if existing.ContentWarning != status.ContentWarning { +		cols = append(cols, "content_warning") +		edited = true +	} + +	// Check for edited status sensitive flag. +	if *existing.Sensitive != *status.Sensitive { +		cols = append(cols, "sensitive") +		edited = true +	} + +	// Check for edited status language tag. +	if existing.Language != status.Language { +		cols = append(cols, "language") +		edited = true +	} + +	if pollChanged { +		// Attached poll was changed. +		cols = append(cols, "poll_id") +		edited = true +	} + +	if mentionsChanged { +		cols = append(cols, "mentions") // i.e. MentionIDs + +		// Mentions changed doesn't necessarily +		// indicate an edit, it may just not have +		// been previously populated properly. +	} + +	if threadChanged { +		cols = append(cols, "thread_id") + +		// Thread changed doesn't necessarily +		// indicate an edit, it may just now +		// actually be included in a thread. +	} + +	if tagsChanged { +		cols = append(cols, "tags") // i.e. TagIDs + +		// Tags changed doesn't necessarily +		// indicate an edit, it may just not have +		// been previously populated properly. +	} + +	if mediaChanged { +		// Attached media was changed. +		cols = append(cols, "attachments") // i.e. AttachmentIDs +		edited = true +	} + +	if emojiChanged { +		// Attached emojis changed. +		cols = append(cols, "emojis") // i.e. EmojiIDs + +		// Emojis changed doesn't necessarily +		// indicate an edit, it may just not have +		// been previously populated properly. +	} + +	if edited { +		// We prefer to use provided 'upated_at', but ensure +		// it fits chronologically with creation / last update. +		if !status.UpdatedAt.After(status.CreatedAt) || +			!status.UpdatedAt.After(existing.UpdatedAt) { + +			// Else fallback to now as update time. +			status.UpdatedAt = status.FetchedAt +		} + +		// Status has been editted since last +		// we saw it, take snapshot of existing. +		var edit gtsmodel.StatusEdit +		edit.ID = id.NewULIDFromTime(status.UpdatedAt) +		edit.Content = existing.Content +		edit.ContentWarning = existing.ContentWarning +		edit.Text = existing.Text +		edit.Language = existing.Language +		edit.Sensitive = existing.Sensitive +		edit.StatusID = status.ID + +		// Copy existing attachments and descriptions. +		edit.AttachmentIDs = existing.AttachmentIDs +		edit.Attachments = existing.Attachments +		if l := len(existing.Attachments); l > 0 { +			edit.AttachmentDescriptions = make([]string, l) +			for i, attach := range existing.Attachments { +				edit.AttachmentDescriptions[i] = attach.Description +			} +		} + +		// Edit creation is last update time. +		edit.CreatedAt = existing.UpdatedAt + +		if existing.Poll != nil { +			// Poll only set if existing contained them. +			edit.PollOptions = existing.Poll.Options + +			if !*existing.Poll.HideCounts || pollChanged { +				// If the counts are allowed to be +				// shown, or poll has changed, then +				// include poll vote counts in edit. +				edit.PollVotes = existing.Poll.Votes +			} +		} + +		// Insert this new edit of existing status into database. +		if err := d.state.DB.PutStatusEdit(ctx, &edit); err != nil { +			return nil, gtserror.Newf("error putting edit in database: %w", err) +		} + +		// Add edit to list of edits on the status. +		status.EditIDs = append(status.EditIDs, edit.ID) +		status.Edits = append(status.Edits, &edit) + +		// Add updated_at and edits to list of cols. +		cols = append(cols, "updated_at", "edits") +	} + +	return cols, nil +} +  // getPopulatedMention tries to populate the given  // mention with the correct TargetAccount and (if not  // yet set) TargetAccountURI, returning the populated diff --git a/internal/federation/dereferencing/status_permitted.go b/internal/federation/dereferencing/status_permitted.go index 9ad425c2f..5d05c5de4 100644 --- a/internal/federation/dereferencing/status_permitted.go +++ b/internal/federation/dereferencing/status_permitted.go @@ -62,6 +62,7 @@ func (d *Dereferencer) isPermittedStatus(  	requestUser string,  	existing *gtsmodel.Status,  	status *gtsmodel.Status, +	isNew bool,  ) (  	permitted bool, // is permitted?  	err error, @@ -98,7 +99,7 @@ func (d *Dereferencer) isPermittedStatus(  		permitted = true  	} -	if !permitted && existing != nil { +	if !permitted && !isNew {  		log.Infof(ctx, "deleting unpermitted: %s", existing.URI)  		// Delete existing status from database as it's no longer permitted. @@ -110,11 +111,13 @@ func (d *Dereferencer) isPermittedStatus(  	return  } +// isPermittedReply ...  func (d *Dereferencer) isPermittedReply(  	ctx context.Context,  	requestUser string,  	reply *gtsmodel.Status,  ) (bool, error) { +  	var (  		replyURI     = reply.URI           // Definitely set.  		inReplyToURI = reply.InReplyToURI  // Definitely set. @@ -149,8 +152,7 @@ func (d *Dereferencer) isPermittedReply(  		// If this status's parent was rejected,  		// implicitly this reply should be too;  		// there's nothing more to check here. -		return false, d.unpermittedByParent( -			ctx, +		return false, d.unpermittedByParent(ctx,  			reply,  			thisReq,  			parentReq, @@ -164,6 +166,7 @@ func (d *Dereferencer) isPermittedReply(  	// be approved, then we should just reject it  	// again, as nothing's changed since last time.  	if thisRejected && acceptIRI == "" { +  		// Nothing changed,  		// still rejected.  		return false, nil @@ -174,6 +177,7 @@ func (d *Dereferencer) isPermittedReply(  	// to be approved. Continue permission checks.  	if inReplyTo == nil { +  		// If we didn't have the replied-to status  		// in our database (yet), we can't check  		// right now if this reply is permitted. diff --git a/internal/federation/dereferencing/status_test.go b/internal/federation/dereferencing/status_test.go index 3b2c2bff2..4b3bd6d67 100644 --- a/internal/federation/dereferencing/status_test.go +++ b/internal/federation/dereferencing/status_test.go @@ -21,14 +21,21 @@ import (  	"context"  	"fmt"  	"testing" +	"time"  	"github.com/stretchr/testify/suite" +	"github.com/superseriousbusiness/activity/streams"  	"github.com/superseriousbusiness/gotosocial/internal/ap"  	"github.com/superseriousbusiness/gotosocial/internal/db" +	"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing"  	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" +	"github.com/superseriousbusiness/gotosocial/internal/util"  	"github.com/superseriousbusiness/gotosocial/testrig"  ) +// instantFreshness is the shortest possible freshness window. +var instantFreshness = util.Ptr(dereferencing.FreshnessWindow(0)) +  type StatusTestSuite struct {  	DereferencerStandardTestSuite  } @@ -229,6 +236,219 @@ func (suite *StatusTestSuite) TestDereferenceStatusWithNonMatchingURI() {  	suite.Nil(fetchedStatus)  } +func (suite *StatusTestSuite) TestDereferencerRefreshStatusUpdated() { +	// Create a new context for this test. +	ctx, cncl := context.WithCancel(context.Background()) +	defer cncl() + +	// The local account we will be fetching statuses as. +	fetchingAccount := suite.testAccounts["local_account_1"] + +	// The test status in question that we will be dereferencing from "remote". +	testURIStr := "https://unknown-instance.com/users/brand_new_person/statuses/01FE4NTHKWW7THT67EF10EB839" +	testURI := testrig.URLMustParse(testURIStr) +	testStatusable := suite.client.TestRemoteStatuses[testURIStr] + +	// Fetch the remote status first to load it into instance. +	testStatus, statusable, err := suite.dereferencer.GetStatusByURI(ctx, +		fetchingAccount.Username, +		testURI, +	) +	suite.NotNil(statusable) +	suite.NoError(err) + +	// Run through multiple possible edits. +	for _, testCase := range []struct { +		editedContent        string +		editedContentWarning string +		editedLanguage       string +		editedSensitive      bool +		editedAttachmentIDs  []string +		editedPollOptions    []string +		editedPollVotes      []int +		editedAt             time.Time +	}{ +		{ +			editedContent:        "updated status content!", +			editedContentWarning: "CW: edited status content", +			editedLanguage:       testStatus.Language,        // no change +			editedSensitive:      *testStatus.Sensitive,      // no change +			editedAttachmentIDs:  testStatus.AttachmentIDs,   // no change +			editedPollOptions:    getPollOptions(testStatus), // no change +			editedPollVotes:      getPollVotes(testStatus),   // no change +			editedAt:             time.Now(), +		}, +	} { +		// Take a snapshot of current +		// state of the test status. +		testStatus = copyStatus(testStatus) + +		// Edit the "remote" statusable obj. +		suite.editStatusable(testStatusable, +			testCase.editedContent, +			testCase.editedContentWarning, +			testCase.editedLanguage, +			testCase.editedSensitive, +			testCase.editedAttachmentIDs, +			testCase.editedPollOptions, +			testCase.editedPollVotes, +			testCase.editedAt, +		) + +		// Refresh with a given statusable to updated to edited copy. +		latest, statusable, err := suite.dereferencer.RefreshStatus(ctx, +			fetchingAccount.Username, +			testStatus, +			nil, // NOTE: can provide testStatusable here to test as being received (not deref'd) +			instantFreshness, +		) +		suite.NotNil(statusable) +		suite.NoError(err) + +		// verify updated status details. +		suite.verifyEditedStatusUpdate( + +			// the original status +			// before any changes. +			testStatus, + +			// latest status +			// being tested. +			latest, + +			// expected current state. +			>smodel.StatusEdit{ +				Content:        testCase.editedContent, +				ContentWarning: testCase.editedContentWarning, +				Language:       testCase.editedLanguage, +				Sensitive:      &testCase.editedSensitive, +				AttachmentIDs:  testCase.editedAttachmentIDs, +				PollOptions:    testCase.editedPollOptions, +				PollVotes:      testCase.editedPollVotes, +				// createdAt never changes +			}, + +			// expected historic edit. +			>smodel.StatusEdit{ +				Content:        testStatus.Content, +				ContentWarning: testStatus.ContentWarning, +				Language:       testStatus.Language, +				Sensitive:      testStatus.Sensitive, +				AttachmentIDs:  testStatus.AttachmentIDs, +				PollOptions:    getPollOptions(testStatus), +				PollVotes:      getPollVotes(testStatus), +				CreatedAt:      testStatus.UpdatedAt, +			}, +		) +	} +} + +// editStatusable updates the given statusable attributes. +// note that this acts on the original object, no copying. +func (suite *StatusTestSuite) editStatusable( +	statusable ap.Statusable, +	content string, +	contentWarning string, +	language string, +	sensitive bool, +	attachmentIDs []string, // TODO: this will require some thinking as to how ... +	pollOptions []string, // TODO: this will require changing statusable type to question +	pollVotes []int, // TODO: this will require changing statusable type to question +	editedAt time.Time, +) { +	// simply reset all mentions / emojis / tags +	statusable.SetActivityStreamsTag(nil) + +	// Update the statusable content property + language (if set). +	contentProp := streams.NewActivityStreamsContentProperty() +	statusable.SetActivityStreamsContent(contentProp) +	contentProp.AppendXMLSchemaString(content) +	if language != "" { +		contentProp.AppendRDFLangString(map[string]string{ +			language: content, +		}) +	} + +	// Update the statusable content-warning property. +	summaryProp := streams.NewActivityStreamsSummaryProperty() +	statusable.SetActivityStreamsSummary(summaryProp) +	summaryProp.AppendXMLSchemaString(contentWarning) + +	// Update the statusable sensitive property. +	sensitiveProp := streams.NewActivityStreamsSensitiveProperty() +	statusable.SetActivityStreamsSensitive(sensitiveProp) +	sensitiveProp.AppendXMLSchemaBoolean(sensitive) + +	// Update the statusable updated property. +	ap.SetUpdated(statusable, editedAt) +} + +// verifyEditedStatusUpdate verifies that a given status has +// the expected number of historic edits, the 'current' status +// attributes (encapsulated as an edit for minimized no. args), +// and the last given 'historic' status edit attributes. +func (suite *StatusTestSuite) verifyEditedStatusUpdate( +	testStatus *gtsmodel.Status, // the original model +	status *gtsmodel.Status, // the status to check +	current *gtsmodel.StatusEdit, // expected current state +	historic *gtsmodel.StatusEdit, // historic edit we expect to have +) { +	// don't use this func +	// name in error msgs. +	suite.T().Helper() + +	// Check we have expected number of edits. +	previousEdits := len(testStatus.Edits) +	suite.Len(status.Edits, previousEdits+1) +	suite.Len(status.EditIDs, previousEdits+1) + +	// Check current state of status. +	suite.Equal(current.Content, status.Content) +	suite.Equal(current.ContentWarning, status.ContentWarning) +	suite.Equal(current.Language, status.Language) +	suite.Equal(*current.Sensitive, *status.Sensitive) +	suite.Equal(current.AttachmentIDs, status.AttachmentIDs) +	suite.Equal(current.PollOptions, getPollOptions(status)) +	suite.Equal(current.PollVotes, getPollVotes(status)) + +	// Check the latest historic edit matches expected. +	latestEdit := status.Edits[len(status.Edits)-1] +	suite.Equal(historic.Content, latestEdit.Content) +	suite.Equal(historic.ContentWarning, latestEdit.ContentWarning) +	suite.Equal(historic.Language, latestEdit.Language) +	suite.Equal(*historic.Sensitive, *latestEdit.Sensitive) +	suite.Equal(historic.AttachmentIDs, latestEdit.AttachmentIDs) +	suite.Equal(historic.PollOptions, latestEdit.PollOptions) +	suite.Equal(historic.PollVotes, latestEdit.PollVotes) +	suite.Equal(historic.CreatedAt, latestEdit.CreatedAt) + +	// The status creation date should never change. +	suite.Equal(testStatus.CreatedAt, status.CreatedAt) +} +  func TestStatusTestSuite(t *testing.T) {  	suite.Run(t, new(StatusTestSuite))  } + +// copyStatus returns a copy of the given status model (not including sub-structs). +func copyStatus(status *gtsmodel.Status) *gtsmodel.Status { +	copy := new(gtsmodel.Status) +	*copy = *status +	return copy +} + +// getPollOptions extracts poll option strings from status (if poll is set). +func getPollOptions(status *gtsmodel.Status) []string { +	if status.Poll != nil { +		return status.Poll.Options +	} +	return nil +} + +// getPollVotes extracts poll vote counts from status (if poll is set). +func getPollVotes(status *gtsmodel.Status) []int { +	if status.Poll != nil { +		return status.Poll.Votes +	} +	return nil +} diff --git a/internal/federation/dereferencing/util.go b/internal/federation/dereferencing/util.go index 297e90adc..208117660 100644 --- a/internal/federation/dereferencing/util.go +++ b/internal/federation/dereferencing/util.go @@ -52,15 +52,15 @@ func emojiChanged(existing, latest *gtsmodel.Emoji) bool {  // pollChanged returns whether a poll has changed in way that  // indicates that this should be an entirely new poll. i.e. if -// the available options have changed, or the expiry has increased. +// the available options have changed, or the expiry has changed.  func pollChanged(existing, latest *gtsmodel.Poll) bool {  	return !slices.Equal(existing.Options, latest.Options) ||  		!existing.ExpiresAt.Equal(latest.ExpiresAt)  } -// pollUpdated returns whether a poll has updated, i.e. if the +// pollStateUpdated returns whether a poll has updated, i.e. if  // vote counts have changed, or if it has expired / been closed. -func pollUpdated(existing, latest *gtsmodel.Poll) bool { +func pollStateUpdated(existing, latest *gtsmodel.Poll) bool {  	return *existing.Voters != *latest.Voters ||  		!slices.Equal(existing.Votes, latest.Votes) ||  		!existing.ClosedAt.Equal(latest.ClosedAt) diff --git a/internal/federation/federatingdb/announce_test.go b/internal/federation/federatingdb/announce_test.go index 264279253..5bb2fc877 100644 --- a/internal/federation/federatingdb/announce_test.go +++ b/internal/federation/federatingdb/announce_test.go @@ -79,7 +79,7 @@ func (suite *AnnounceTestSuite) TestAnnounceTwice() {  	// Insert the boost-of status into the  	// DB cache to emulate processor handling -	boost.ID, _ = id.NewULIDFromTime(boost.CreatedAt) +	boost.ID = id.NewULIDFromTime(boost.CreatedAt)  	suite.state.Caches.DB.Status.Put(boost)  	// only the URI will be set for the boosted status | 
