summaryrefslogtreecommitdiff
path: root/internal/federation
diff options
context:
space:
mode:
Diffstat (limited to 'internal/federation')
-rw-r--r--internal/federation/dereferencing/status.go107
-rw-r--r--internal/federation/federatingdb/create.go123
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: &gtsmodel.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