[feature] Support new model of interaction flow for forward compat with v0.21.0 (#4394)

~~Still WIP!~~

This PR allows v0.20.0 of GtS to be forward-compatible with the interaction request / authorization flow that will fully replace the current flow in v0.21.0.

Basically, this means we need to recognize LikeRequest, ReplyRequest, and AnnounceRequest, and in response to those requests, deliver either a Reject or an Accept, with the latter pointing towards a LikeAuthorization, ReplyAuthorization, or AnnounceAuthorization, respectively. This can then be used by the remote instance to prove to third parties that the interaction has been accepted by the interactee. These Authorization types need to be dereferencable to third parties, so we need to serve them.

As well as recognizing the above "polite" interaction request types, we also need to still serve appropriate responses to "impolite" interaction request types, where an instance that's unaware of interaction policies tries to interact with a post by sending a reply, like, or boost directly, without wrapping it in a WhateverRequest type.

Doesn't fully close https://codeberg.org/superseriousbusiness/gotosocial/issues/4026 but gets damn near (just gotta update the federating with GtS documentation).

Migrations tested on both Postgres and SQLite.

Co-authored-by: kim <grufwub@gmail.com>
Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4394
Co-authored-by: tobi <tobi.smethurst@protonmail.com>
Co-committed-by: tobi <tobi.smethurst@protonmail.com>
This commit is contained in:
tobi 2025-09-14 15:37:35 +02:00 committed by tobi
commit 754b7be9cf
126 changed files with 6637 additions and 1778 deletions

View file

@ -106,12 +106,7 @@ func (a *adminDB) NewSignup(ctx context.Context, newSignup gtsmodel.NewSignup) (
// with this username, create one now.
if account == nil {
uris := uris.GenerateURIsForAccount(newSignup.Username)
accountID, err := id.NewRandomULID()
if err != nil {
err := gtserror.Newf("error creating new account id: %w", err)
return nil, err
}
accountID := id.NewRandomULID()
privKey, err := rsa.GenerateKey(rand.Reader, rsaKeyBits)
if err != nil {
@ -174,12 +169,9 @@ func (a *adminDB) NewSignup(ctx context.Context, newSignup gtsmodel.NewSignup) (
return user, nil
}
// Had no user for this account, time to create one!
newUserID, err := id.NewRandomULID()
if err != nil {
err := gtserror.Newf("error creating new user id: %w", err)
return nil, err
}
// Had no user for this
// account, time to create one!
newUserID := id.NewRandomULID()
encryptedPassword, err := bcrypt.GenerateFromPassword(
[]byte(newSignup.Password),
@ -273,14 +265,9 @@ func (a *adminDB) CreateInstanceAccount(ctx context.Context) error {
return err
}
aID, err := id.NewRandomULID()
if err != nil {
return err
}
newAccountURIs := uris.GenerateURIsForAccount(username)
acct := &gtsmodel.Account{
ID: aID,
ID: id.NewRandomULID(),
Username: username,
DisplayName: username,
URL: newAccountURIs.UserURL,
@ -325,13 +312,8 @@ func (a *adminDB) CreateInstanceInstance(ctx context.Context) error {
return nil
}
iID, err := id.NewRandomULID()
if err != nil {
return err
}
i := &gtsmodel.Instance{
ID: iID,
ID: id.NewRandomULID(),
Domain: host,
Title: host,
URI: fmt.Sprintf("%s://%s", protocol, host),

View file

@ -58,31 +58,45 @@ func (i *interactionDB) GetInteractionRequestByID(ctx context.Context, id string
)
}
func (i *interactionDB) GetInteractionRequestByInteractionURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) {
func (i *interactionDB) GetInteractionRequestByInteractionURI(ctx context.Context, intURI string) (*gtsmodel.InteractionRequest, error) {
return i.getInteractionRequest(
ctx,
"InteractionURI",
func(request *gtsmodel.InteractionRequest) error {
return i.
newInteractionRequestQ(request).
Where("? = ?", bun.Ident("interaction_request.interaction_uri"), uri).
Where("? = ?", bun.Ident("interaction_request.interaction_uri"), intURI).
Scan(ctx)
},
uri,
intURI,
)
}
func (i *interactionDB) GetInteractionRequestByURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error) {
func (i *interactionDB) GetInteractionRequestByResponseURI(ctx context.Context, respURI string) (*gtsmodel.InteractionRequest, error) {
return i.getInteractionRequest(
ctx,
"URI",
"ResponseURI",
func(request *gtsmodel.InteractionRequest) error {
return i.
newInteractionRequestQ(request).
Where("? = ?", bun.Ident("interaction_request.uri"), uri).
Where("? = ?", bun.Ident("interaction_request.response_uri"), respURI).
Scan(ctx)
},
uri,
respURI,
)
}
func (i *interactionDB) GetInteractionRequestByAuthorizationURI(ctx context.Context, authURI string) (*gtsmodel.InteractionRequest, error) {
return i.getInteractionRequest(
ctx,
"AuthorizationURI",
func(request *gtsmodel.InteractionRequest) error {
return i.
newInteractionRequestQ(request).
Where("? = ?", bun.Ident("interaction_request.authorization_uri"), authURI).
Scan(ctx)
},
authURI,
)
}
@ -173,11 +187,11 @@ func (i *interactionDB) PopulateInteractionRequest(ctx context.Context, req *gts
errs = gtserror.NewMultiError(4)
)
if req.Status == nil {
if req.TargetStatus == nil {
// Target status is not set, fetch from the database.
req.Status, err = i.state.DB.GetStatusByID(
req.TargetStatus, err = i.state.DB.GetStatusByID(
gtscontext.SetBarebones(ctx),
req.StatusID,
req.TargetStatusID,
)
if err != nil {
errs.Appendf("error populating interactionRequest target: %w", err)

View file

@ -57,9 +57,8 @@ func (suite *InteractionTestSuite) markInteractionsPending(
suite.FailNow(err.Error())
}
// Put an interaction request
// in the DB for this reply.
req := typeutils.StatusToInteractionRequest(reply)
// Put an impolite interaction request in the DB for this reply.
req := typeutils.StatusToImpoliteInteractionRequest(reply)
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
@ -84,9 +83,8 @@ func (suite *InteractionTestSuite) markInteractionsPending(
suite.FailNow(err.Error())
}
// Put an interaction request
// in the DB for this boost.
req := typeutils.StatusToInteractionRequest(boost)
// Put an impolite interaction request in the DB for this boost.
req := typeutils.StatusToImpoliteInteractionRequest(boost)
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
@ -111,9 +109,8 @@ func (suite *InteractionTestSuite) markInteractionsPending(
suite.FailNow(err.Error())
}
// Put an interaction request
// in the DB for this fave.
req := typeutils.StatusFaveToInteractionRequest(fave)
// Put an impolite interaction request in the DB for this fave.
req := typeutils.StatusFaveToImpoliteInteractionRequest(fave)
if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil {
suite.FailNow(err.Error())
}
@ -229,8 +226,8 @@ func (suite *InteractionTestSuite) TestInteractionRejected() {
// Update the interaction request to mark it rejected.
req.RejectedAt = time.Now()
req.URI = "https://some.reject.uri"
if err := suite.state.DB.UpdateInteractionRequest(ctx, req, "uri", "rejected_at"); err != nil {
req.ResponseURI = "https://some.reject.uri"
if err := suite.state.DB.UpdateInteractionRequest(ctx, req, "response_uri", "rejected_at"); err != nil {
suite.FailNow(err.Error())
}

View file

@ -0,0 +1,328 @@
// 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 migrations
import (
"context"
"database/sql"
"errors"
"net/url"
"strings"
"code.superseriousbusiness.org/gotosocial/internal/config"
"code.superseriousbusiness.org/gotosocial/internal/gtserror"
"code.superseriousbusiness.org/gotosocial/internal/id"
"code.superseriousbusiness.org/gotosocial/internal/log"
"code.superseriousbusiness.org/gotosocial/internal/util"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect"
new_gtsmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20250715095446_int_pols_forward_compat/new"
old_gtsmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20250715095446_int_pols_forward_compat/old"
)
func init() {
up := func(ctx context.Context, db *bun.DB) error {
const tmpTableName = "new_interaction_requests"
const tableName = "interaction_requests"
var host = config.GetHost()
var accountDomain = config.GetAccountDomain()
// Count number of interaction
// requests we need to update.
total, err := db.NewSelect().
Table(tableName).
Count(ctx)
if err != nil {
return gtserror.Newf("error geting interaction requests table count: %w", err)
}
// Create new interaction_requests table and convert all existing into it.
if err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
log.Info(ctx, "creating new interaction_requests table")
if _, err := tx.NewCreateTable().
ModelTableExpr(tmpTableName).
Model((*new_gtsmodel.InteractionRequest)(nil)).
Exec(ctx); err != nil {
return gtserror.Newf("error creating new interaction requests table: %w", err)
}
// Conversion batch size.
const batchsz = 1000
var maxID string
var count int
// Start at largest
// possible ULID value.
maxID = id.Highest
// Preallocate interaction request slices to maximum possible size.
oldRequests := make([]*old_gtsmodel.InteractionRequest, 0, batchsz)
newRequests := make([]*new_gtsmodel.InteractionRequest, 0, batchsz)
log.Info(ctx, "migrating interaction requests to new table, this may take some time!")
outer:
for {
// Reset slices slices.
clear(oldRequests)
clear(newRequests)
oldRequests = oldRequests[:0]
newRequests = newRequests[:0]
// Select next batch of
// interaction requests.
if err := tx.NewSelect().
Model(&oldRequests).
Where("? < ?", bun.Ident("id"), maxID).
OrderExpr("? DESC", bun.Ident("id")).
Limit(batchsz).
Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) {
return gtserror.Newf("error selecting interaction requests: %w", err)
}
// Reached end of requests.
if len(oldRequests) == 0 {
break outer
}
// Set next maxID value from old requests.
maxID = oldRequests[len(oldRequests)-1].ID
inner:
// Convert old request models to new.
for _, oldRequest := range oldRequests {
newRequest := &new_gtsmodel.InteractionRequest{
ID: oldRequest.ID,
TargetStatusID: oldRequest.StatusID,
TargetAccountID: oldRequest.TargetAccountID,
InteractingAccountID: oldRequest.InteractingAccountID,
InteractionURI: oldRequest.InteractionURI,
InteractionType: int16(oldRequest.InteractionType), // #nosec G115
Polite: util.Ptr(false), // old requests were always impolite
AcceptedAt: oldRequest.AcceptedAt,
RejectedAt: oldRequest.RejectedAt,
ResponseURI: oldRequest.URI,
}
// Append new request to slice,
// though we continue operating on
// its ptr in the rest of this loop.
newRequests = append(newRequests,
newRequest)
// Re-use the original interaction URI to create
// a mock interaction request URI on the new model.
switch oldRequest.InteractionType {
case old_gtsmodel.InteractionLike:
newRequest.InteractionRequestURI = oldRequest.InteractionURI + new_gtsmodel.LikeRequestSuffix
case old_gtsmodel.InteractionReply:
newRequest.InteractionRequestURI = oldRequest.InteractionURI + new_gtsmodel.ReplyRequestSuffix
case old_gtsmodel.InteractionAnnounce:
newRequest.InteractionRequestURI = oldRequest.InteractionURI + new_gtsmodel.AnnounceRequestSuffix
}
// If the request was accepted by us, then generate an authorization
// URI for it, in order to be able to serve an Authorization if necessary.
if oldRequest.AcceptedAt.IsZero() || oldRequest.URI == "" {
// Wasn't accepted,
// nothing else to do.
continue inner
}
// Parse URI details of accept URI string.
acceptURI, err := url.Parse(oldRequest.URI)
if err != nil {
log.Warnf(ctx, "could not parse oldRequest.URI for interaction request %s,"+
" skipping forward-compat hack (don't worry, this is not a big deal): %v",
oldRequest.ID, err)
continue inner
}
// Check whether accept URI originated from this instance.
if !(acceptURI.Host == host || acceptURI.Host == accountDomain) {
// Not an accept from
// us, leave it alone.
continue inner
}
// Reuse the Accept URI to create an Authorization URI.
// Creates `https://example.org/users/aaa/authorizations/[ID]`
// from `https://example.org/users/aaa/accepts/[ID]`.
authorizationURI := strings.ReplaceAll(
oldRequest.URI,
"/accepts/"+oldRequest.ID,
"/authorizations/"+oldRequest.ID,
)
newRequest.AuthorizationURI = authorizationURI
var updateTableName string
// Determine which table will have corresponding approved_by_uri.
if oldRequest.InteractionType == old_gtsmodel.InteractionLike {
updateTableName = "status_faves"
} else {
updateTableName = "statuses"
}
// Update the corresponding interaction
// with generated authorization URI.
if _, err := tx.NewUpdate().
Table(updateTableName).
Set("? = ?", bun.Ident("approved_by_uri"), authorizationURI).
Where("? = ?", bun.Ident("uri"), oldRequest.InteractionURI).
Exec(ctx); err != nil {
return gtserror.Newf("error updating approved_by_uri: %w", err)
}
}
// Insert converted interaction
// request models to new table.
if _, err := tx.
NewInsert().
Model(&newRequests).
Exec(ctx); err != nil {
return gtserror.Newf("error inserting interaction requests: %w", err)
}
// Increment insert count.
count += len(newRequests)
log.Infof(ctx, "[%d of %d] converting interaction requests", count, total)
}
return nil
}); err != nil {
return err
}
// Ensure that the above transaction
// has gone ahead without issues.
//
// Also placing this here might make
// breaking this into piecemeal steps
// easier if turns out necessary.
newTotal, err := db.NewSelect().
Table(tmpTableName).
Count(ctx)
if err != nil {
return gtserror.Newf("error geting new interaction requests table count: %w", err)
} else if total != newTotal {
return gtserror.Newf("new interaction requests table contains unexpected count %d, want %d", newTotal, total)
}
// Attempt to merge any sqlite write-ahead-log.
if err := doWALCheckpoint(ctx, db); err != nil {
return err
}
// Drop the old interaction requests table and rename new one to replace it.
if err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
log.Info(ctx, "dropping old interaction_requests table")
if _, err := tx.NewDropTable().
Table(tableName).
Exec(ctx); err != nil {
return gtserror.Newf("error dropping old interaction requests table: %w", err)
}
log.Info(ctx, "renaming new interaction_requests table to old")
if _, err := tx.NewRaw("ALTER TABLE ? RENAME TO ?",
bun.Ident(tmpTableName),
bun.Ident(tableName),
).Exec(ctx); err != nil {
return gtserror.Newf("error renaming interaction requests table: %w", err)
}
// Create necessary indices on the new table.
for index, columns := range map[string][]string{
"interaction_requests_target_status_id_idx": {"target_status_id"},
"interaction_requests_interacting_account_id_idx": {"interacting_account_id"},
"interaction_requests_target_account_id_idx": {"target_account_id"},
"interaction_requests_accepted_at_idx": {"accepted_at"},
"interaction_requests_rejected_at_idx": {"rejected_at"},
} {
log.Infof(ctx, "recreating %s index", index)
if _, err := tx.NewCreateIndex().
Table(tableName).
Index(index).
Column(columns...).
Exec(ctx); err != nil {
return err
}
}
if tx.Dialect().Name() == dialect.PG {
// Rename postgres uniqueness constraints:
// "new_interaction_requests_*" -> "interaction_requests_*"
log.Info(ctx, "renaming interaction_requests constraints on new table")
for _, spec := range []struct {
old string
new string
}{
{
old: "new_interaction_requests_pkey",
new: "interaction_requests_pkey",
},
{
old: "new_interaction_requests_interaction_request_uri_key",
new: "interaction_requests_interaction_request_uri_key",
},
{
old: "new_interaction_requests_interaction_uri_key",
new: "interaction_requests_interaction_uri_key",
},
{
old: "new_interaction_requests_response_uri_key",
new: "interaction_requests_response_uri_key",
},
{
old: "new_interaction_requests_authorization_uri_key",
new: "interaction_requests_authorization_uri_key",
},
} {
if _, err := tx.NewRaw("ALTER TABLE ? RENAME CONSTRAINT ? TO ?",
bun.Ident(tableName),
bun.Safe(spec.old),
bun.Safe(spec.new),
).Exec(ctx); err != nil {
return gtserror.Newf("error renaming postgres interaction requests constraint %s: %w", spec.new, err)
}
}
}
return nil
}); err != nil {
return err
}
// Final sqlite write-ahead-log merge.
return doWALCheckpoint(ctx, db)
}
down := func(ctx context.Context, db *bun.DB) error {
return nil
}
if err := Migrations.Register(up, down); err != nil {
panic(err)
}
}

View file

@ -0,0 +1,68 @@
// 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 gtsmodel
import (
"time"
"github.com/uptrace/bun"
)
type InteractionRequest struct {
// Used only for migration.
bun.BaseModel `bun:"table:new_interaction_requests"`
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
// Removed in new model.
// CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
// Renamed from "StatusID" to "TargetStatusID" in new model.
TargetStatusID string `bun:"type:CHAR(26),nullzero,notnull"`
TargetAccountID string `bun:"type:CHAR(26),nullzero,notnull"`
InteractingAccountID string `bun:"type:CHAR(26),nullzero,notnull"`
// Added in new model.
InteractionRequestURI string `bun:",nullzero,notnull,unique"`
InteractionURI string `bun:",nullzero,notnull,unique"`
// Changed type from int to int16 in new model.
InteractionType int16 `bun:",notnull"`
// Added in new model.
Polite *bool `bun:",nullzero,notnull,default:false"`
AcceptedAt time.Time `bun:"type:timestamptz,nullzero"`
RejectedAt time.Time `bun:"type:timestamptz,nullzero"`
// Renamed from "URI" to "ResponseURI" in new model.
ResponseURI string `bun:",nullzero,unique"`
// Added in new model.
AuthorizationURI string `bun:",nullzero,unique"`
}
const (
LikeRequestSuffix = "#LikeRequest"
ReplyRequestSuffix = "#ReplyRequest"
AnnounceRequestSuffix = "#AnnounceRequest"
)

View file

@ -0,0 +1,39 @@
// 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 gtsmodel
import "time"
type InteractionRequest struct {
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"`
StatusID string `bun:"type:CHAR(26),nullzero,notnull"`
TargetAccountID string `bun:"type:CHAR(26),nullzero,notnull"`
InteractingAccountID string `bun:"type:CHAR(26),nullzero,notnull"`
InteractionURI string `bun:",nullzero,notnull,unique"`
InteractionType int `bun:",notnull"`
AcceptedAt time.Time `bun:"type:timestamptz,nullzero"`
RejectedAt time.Time `bun:"type:timestamptz,nullzero"`
URI string `bun:",nullzero,unique"`
}
const (
InteractionLike int = 0
InteractionReply int = 1
InteractionAnnounce int = 2
)

View file

@ -46,21 +46,7 @@ func (suite *NotificationTestSuite) spamNotifs() {
if i%2 == 0 {
targetAccountID = zork.ID
} else {
randomAssID, err := id.NewRandomULID()
if err != nil {
panic(err)
}
targetAccountID = randomAssID
}
statusID, err := id.NewRandomULID()
if err != nil {
panic(err)
}
originAccountID, err := id.NewRandomULID()
if err != nil {
panic(err)
targetAccountID = id.NewRandomULID()
}
notif := &gtsmodel.Notification{
@ -68,8 +54,8 @@ func (suite *NotificationTestSuite) spamNotifs() {
NotificationType: gtsmodel.NotificationFavourite,
CreatedAt: time.Now(),
TargetAccountID: targetAccountID,
OriginAccountID: originAccountID,
StatusOrEditID: statusID,
OriginAccountID: id.NewRandomULID(),
StatusOrEditID: id.NewRandomULID(),
Read: util.Ptr(false),
}

View file

@ -28,12 +28,17 @@ type Interaction interface {
// GetInteractionRequestByID gets one request with the given id.
GetInteractionRequestByID(ctx context.Context, id string) (*gtsmodel.InteractionRequest, error)
// GetInteractionRequestByID gets one request with the given interaction uri.
GetInteractionRequestByInteractionURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error)
// GetInteractionRequestByID gets one request with the given interaction
// uri (ie., the URI of the requested like, reply, or announce).
GetInteractionRequestByInteractionURI(ctx context.Context, intURI string) (*gtsmodel.InteractionRequest, error)
// GetInteractionRequestByURI returns one accepted or rejected
// interaction request with the given URI, if it exists in the db.
GetInteractionRequestByURI(ctx context.Context, uri string) (*gtsmodel.InteractionRequest, error)
// GetInteractionRequestByResponseURI returns one accepted or rejected
// interaction request with the given Accept or Reject URI.
GetInteractionRequestByResponseURI(ctx context.Context, respURI string) (*gtsmodel.InteractionRequest, error)
// GetInteractionRequestByAuthorizationURI returns one accepted
// interaction request with the given authorization URI.
GetInteractionRequestByAuthorizationURI(ctx context.Context, authURI string) (*gtsmodel.InteractionRequest, error)
// PopulateInteractionRequest ensures that the request's struct fields are populated.
PopulateInteractionRequest(ctx context.Context, request *gtsmodel.InteractionRequest) error