diff options
Diffstat (limited to 'internal/federation')
-rw-r--r-- | internal/federation/dereferencing/status.go | 107 | ||||
-rw-r--r-- | internal/federation/federatingdb/create.go | 123 |
2 files changed, 227 insertions, 3 deletions
diff --git a/internal/federation/dereferencing/status.go b/internal/federation/dereferencing/status.go index 4dd6d3baf..1f09dedc0 100644 --- a/internal/federation/dereferencing/status.go +++ b/internal/federation/dereferencing/status.go @@ -365,12 +365,13 @@ func (d *Dereferencer) enrichStatus( // Use existing status ID. latestStatus.ID = status.ID - if latestStatus.ID == "" { + // Generate new status ID from the provided creation date. latestStatus.ID, err = id.NewULIDFromTime(latestStatus.CreatedAt) if err != nil { - return nil, nil, gtserror.Newf("invalid created at date: %w", err) + log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err) + latestStatus.ID = id.NewULID() // just use "now" } } @@ -379,6 +380,11 @@ func (d *Dereferencer) enrichStatus( latestStatus.FetchedAt = time.Now() latestStatus.Local = status.Local + // 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) + } + // 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) @@ -533,7 +539,7 @@ func (d *Dereferencer) fetchStatusMentions(ctx context.Context, requestUser stri // 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) + log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err) mention.ID = id.NewULID() // just use "now" } @@ -681,6 +687,101 @@ func (d *Dereferencer) fetchStatusTags(ctx context.Context, existing, status *gt return nil } +func (d *Dereferencer) fetchStatusPoll(ctx context.Context, existing, 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) + } + if err := d.state.DB.DeletePollVotes(ctx, pollID); err != nil { + return gtserror.Newf("error deleting existing votes 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 /*existing.Poll != nil &&*/ status.Poll == nil: + // existing poll has been deleted, remove this. + return deleteStatusPoll(ctx, existing.PollID) + + case /*existing.Poll != nil && status.Poll != nil && */ + !slices.Equal(existing.Poll.Options, status.Poll.Options) || + !existing.Poll.ExpiresAt.Equal(status.Poll.ExpiresAt): + // poll has changed since original, delete and reinsert new. + if err := deleteStatusPoll(ctx, existing.PollID); err != nil { + return err + } + return insertStatusPoll(ctx, status) + + case /*existing.Poll != nil && status.Poll != nil && */ + !existing.Poll.ClosedAt.Equal(status.Poll.ClosedAt) || + !slices.Equal(existing.Poll.Votes, status.Poll.Votes) || + existing.Poll.Voters != status.Poll.Voters: + // Since we last saw it, the poll has updated! + // Whether that be stats, or close time. + poll := existing.Poll + poll.Closing = (!poll.Closed() && status.Poll.Closed()) + 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 + } +} + func (d *Dereferencer) fetchStatusAttachments(ctx context.Context, tsport transport.Transport, existing, status *gtsmodel.Status) error { // Allocate new slice to take the yet-to-be fetched attachment IDs. status.AttachmentIDs = make([]string, len(status.Attachments)) diff --git a/internal/federation/federatingdb/create.go b/internal/federation/federatingdb/create.go index 0fb459190..3a8d8f0ac 100644 --- a/internal/federation/federatingdb/create.go +++ b/internal/federation/federatingdb/create.go @@ -28,6 +28,7 @@ import ( "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/config" "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/id" @@ -141,6 +142,22 @@ func (f *federatingDB) activityCreate( // Extract objects from create activity. objects := ap.ExtractObjects(create) + // Extract PollOptionables (votes!) from objects slice. + optionables, objects := ap.ExtractPollOptionables(objects) + + if len(optionables) > 0 { + // Handle provided poll vote(s) creation, this can + // be for single or multiple votes in the same poll. + err := f.createPollOptionables(ctx, + receivingAccount, + requestingAccount, + optionables, + ) + if err != nil { + errs.Appendf("error creating poll vote(s): %w", err) + } + } + // Extract Statusables from objects slice (this must be // done AFTER extracting options due to how AS typing works). statusables, objects := ap.ExtractStatusables(objects) @@ -169,6 +186,112 @@ func (f *federatingDB) activityCreate( return errs.Combine() } +// createPollOptionable handles a Create activity for a PollOptionable. +// This function doesn't handle database insertion, only validation checks +// before passing off to a worker for asynchronous processing. +func (f *federatingDB) createPollOptionables( + ctx context.Context, + receiver *gtsmodel.Account, + requester *gtsmodel.Account, + options []ap.PollOptionable, +) error { + var ( + // the origin Status w/ Poll the vote + // options are in. This gets set on first + // iteration, relevant checks performed + // then re-used in each further iteration. + inReplyTo *gtsmodel.Status + + // the resulting slices of Poll.Option + // choice indices passed into the new + // created PollVote object. + choices []int + ) + + for _, option := range options { + // Extract the "inReplyTo" property. + inReplyToURIs := ap.GetInReplyTo(option) + if len(inReplyToURIs) != 1 { + return gtserror.Newf("invalid inReplyTo property length: %d", len(inReplyToURIs)) + } + + // Stringify the inReplyTo URI. + statusURI := inReplyToURIs[0].String() + + if inReplyTo == nil { + var err error + + // This is the first object in the activity slice, + // check database for the poll source status by URI. + inReplyTo, err = f.state.DB.GetStatusByURI(ctx, statusURI) + if err != nil { + return gtserror.Newf("error getting poll source from database %s: %w", statusURI, err) + } + + switch { + // The origin status isn't a poll? + case inReplyTo.PollID == "": + return gtserror.Newf("poll vote in status %s without poll", statusURI) + + // We don't own the poll ... + case !*inReplyTo.Local: + return gtserror.Newf("poll vote in remote status %s", statusURI) + } + + // Check whether user has already vote in this poll. + // (we only check this for the first object, as multiple + // may be sent in response to a multiple-choice poll). + vote, err := f.state.DB.GetPollVoteBy( + gtscontext.SetBarebones(ctx), + inReplyTo.PollID, + requester.ID, + ) + if err != nil && !errors.Is(err, db.ErrNoEntries) { + return gtserror.Newf("error getting status %s poll votes from database: %w", statusURI, err) + } + + if vote != nil { + log.Warnf(ctx, "%s has already voted in poll %s", requester.URI, statusURI) + return nil // this is a useful warning for admins to report to us from logs + } + } + + if statusURI != inReplyTo.URI { + // All activity votes should be to the same poll per activity. + return gtserror.New("votes to multiple polls in single activity") + } + + // Extract the poll option name. + name := ap.ExtractName(option) + + // Check that this is a valid option name. + choice := inReplyTo.Poll.GetChoice(name) + if choice == -1 { + return gtserror.Newf("poll vote in status %s invalid: %s", statusURI, name) + } + + // Append the option index to choices. + choices = append(choices, choice) + } + + // Enqueue message to the fedi API worker with poll vote(s). + f.state.Workers.EnqueueFediAPI(ctx, messages.FromFediAPI{ + APActivityType: ap.ActivityCreate, + APObjectType: ap.ActivityQuestion, + GTSModel: >smodel.PollVote{ + ID: id.NewULID(), + Choices: choices, + AccountID: requester.ID, + Account: requester, + PollID: inReplyTo.PollID, + Poll: inReplyTo.Poll, + }, + ReceivingAccount: receiver, + }) + + return nil +} + // createStatusable handles a Create activity for a Statusable. // This function won't insert anything in the database yet, // but will pass the Statusable (if appropriate) through to |