Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
S0yKaf 2025-01-18 13:55:15 -05:00
commit 0e137c0f2d
1759 changed files with 864109 additions and 314186 deletions

View file

@ -22,6 +22,7 @@ import (
"time"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/admin"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation"
@ -93,6 +94,7 @@ func (suite *AccountStandardTestSuite) SetupTest() {
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
suite.tc = typeutils.NewConverter(&suite.state)
testrig.StartTimelines(

View file

@ -27,7 +27,7 @@ import (
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
)
func (p *Processor) Alias(
@ -137,8 +137,8 @@ func (p *Processor) Alias(
// Dedupe URIs + accounts, in case someone
// provided both an account URL and an
// account URI above, for the same account.
account.AlsoKnownAsURIs = util.Deduplicate(account.AlsoKnownAsURIs)
account.AlsoKnownAs = util.DeduplicateFunc(
account.AlsoKnownAsURIs = xslices.Deduplicate(account.AlsoKnownAsURIs)
account.AlsoKnownAs = xslices.DeduplicateFunc(
account.AlsoKnownAs,
func(a *gtsmodel.Account) string {
return a.URI

View file

@ -80,7 +80,7 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() {
func (suite *GetRSSTestSuite) TestGetAccountRSSZork() {
getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(context.Background(), "the_mighty_zork")
suite.NoError(err)
suite.EqualValues(1704878640, lastModified.Unix())
suite.EqualValues(1730451600, lastModified.Unix())
feed, err := getFeed()
suite.NoError(err)
@ -89,13 +89,23 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSZork() {
<title>Posts from @the_mighty_zork@localhost:8080</title>
<link>http://localhost:8080/@the_mighty_zork</link>
<description>Posts from @the_mighty_zork@localhost:8080</description>
<pubDate>Wed, 10 Jan 2024 09:24:00 +0000</pubDate>
<lastBuildDate>Wed, 10 Jan 2024 09:24:00 +0000</lastBuildDate>
<pubDate>Fri, 01 Nov 2024 09:00:00 +0000</pubDate>
<lastBuildDate>Fri, 01 Nov 2024 09:00:00 +0000</lastBuildDate>
<image>
<url>http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp</url>
<title>Avatar for @the_mighty_zork@localhost:8080</title>
<link>http://localhost:8080/@the_mighty_zork</link>
</image>
<item>
<title>edited status</title>
<link>http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR</link>
<description>@the_mighty_zork@localhost:8080 made a new post: &#34;this is the latest revision of the status, with a content-warning&#34;</description>
<content:encoded><![CDATA[<p>this is the latest revision of the status, with a content-warning</p>]]></content:encoded>
<author>@the_mighty_zork@localhost:8080</author>
<guid isPermaLink="true">http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR</guid>
<pubDate>Fri, 01 Nov 2024 09:00:00 +0000</pubDate>
<source>http://localhost:8080/@the_mighty_zork/feed.rss</source>
</item>
<item>
<title>HTML in post</title>
<link>http://localhost:8080/@the_mighty_zork/statuses/01HH9KYNQPA416TNJ53NSATP40</link>

View file

@ -468,9 +468,10 @@ func (p *Processor) UpdateAvatar(
) {
// Get maximum supported local media size.
maxsz := config.GetMediaLocalMaxSize()
maxszInt64 := int64(maxsz) // #nosec G115 -- Already validated.
// Ensure media within size bounds.
if avatar.Size > int64(maxsz) {
if avatar.Size > maxszInt64 {
text := fmt.Sprintf("media exceeds configured max size: %s", maxsz)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
@ -483,7 +484,7 @@ func (p *Processor) UpdateAvatar(
}
// Wrap the multipart file reader to ensure is limited to max.
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, int64(maxsz))
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, maxszInt64)
// Write to instance storage.
return p.c.StoreLocalMedia(ctx,
@ -513,9 +514,10 @@ func (p *Processor) UpdateHeader(
) {
// Get maximum supported local media size.
maxsz := config.GetMediaLocalMaxSize()
maxszInt64 := int64(maxsz) // #nosec G115 -- Already validated.
// Ensure media within size bounds.
if header.Size > int64(maxsz) {
if header.Size > maxszInt64 {
text := fmt.Sprintf("media exceeds configured max size: %s", maxsz)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
@ -528,7 +530,7 @@ func (p *Processor) UpdateHeader(
}
// Wrap the multipart file reader to ensure is limited to max.
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, int64(maxsz))
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, maxszInt64)
// Write to instance storage.
return p.c.StoreLocalMedia(ctx,

View file

@ -53,7 +53,7 @@ func (suite *AccountTestSuite) TestAccountActionSuspend() {
// Wait for action to finish.
if !testrig.WaitFor(func() bool {
return suite.adminProcessor.Actions().TotalRunning() == 0
return suite.state.AdminActions.TotalRunning() == 0
}) {
suite.FailNow("timed out waiting for admin action(s) to finish")
}

View file

@ -40,7 +40,7 @@ func (p *Processor) AccountAction(
return "", gtserror.NewErrorInternalError(err)
}
switch gtsmodel.NewAdminActionType(request.Type) {
switch gtsmodel.ParseAdminActionType(request.Type) {
case gtsmodel.AdminActionSuspend:
return p.accountActionSuspend(ctx, adminAcct, targetAcct, request.Text)
@ -68,7 +68,7 @@ func (p *Processor) accountActionSuspend(
) (string, gtserror.WithCode) {
actionID := id.NewULID()
errWithCode := p.actions.Run(
errWithCode := p.state.AdminActions.Run(
ctx,
&gtsmodel.AdminAction{
ID: actionID,

View file

@ -1,170 +0,0 @@
// 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 admin
import (
"context"
"slices"
"sync"
"time"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/state"
)
func errActionConflict(action *gtsmodel.AdminAction) gtserror.WithCode {
err := gtserror.NewfAt(
4, // Include caller's function name.
"an action (%s) is currently running (duration %s) which conflicts with the attempted action",
action.Key(), time.Since(action.CreatedAt),
)
const help = "wait until this action is complete and try again"
return gtserror.NewErrorConflict(err, err.Error(), help)
}
type Actions struct {
r map[string]*gtsmodel.AdminAction
state *state.State
// Not embedded struct,
// to shield from access
// by outside packages.
m sync.Mutex
}
// Run runs the given admin action by executing the supplied function.
//
// Run handles locking, action insertion and updating, so you don't have to!
//
// If an action is already running which overlaps/conflicts with the
// given action, an ErrorWithCode 409 will be returned.
//
// If execution of the provided function returns errors, the errors
// will be updated on the provided admin action in the database.
func (a *Actions) Run(
ctx context.Context,
action *gtsmodel.AdminAction,
f func(context.Context) gtserror.MultiError,
) gtserror.WithCode {
actionKey := action.Key()
// LOCK THE MAP HERE, since we're
// going to do some operations on it.
a.m.Lock()
// Bail if an action with
// this key is already running.
running, ok := a.r[actionKey]
if ok {
a.m.Unlock()
return errActionConflict(running)
}
// Action with this key not
// yet running, create it.
if err := a.state.DB.PutAdminAction(ctx, action); err != nil {
err = gtserror.Newf("db error putting admin action %s: %w", actionKey, err)
// Don't store in map
// if there's an error.
a.m.Unlock()
return gtserror.NewErrorInternalError(err)
}
// Action was inserted,
// store in map.
a.r[actionKey] = action
// UNLOCK THE MAP HERE, since
// we're done modifying it for now.
a.m.Unlock()
go func() {
// Use a background context with existing values.
ctx = gtscontext.WithValues(context.Background(), ctx)
// Run the thing and collect errors.
if errs := f(ctx); errs != nil {
action.Errors = make([]string, 0, len(errs))
for _, err := range errs {
action.Errors = append(action.Errors, err.Error())
}
}
// Action is no longer running:
// remove from running map.
a.m.Lock()
delete(a.r, actionKey)
a.m.Unlock()
// Mark as completed in the db,
// storing errors for later review.
action.CompletedAt = time.Now()
if err := a.state.DB.UpdateAdminAction(ctx, action, "completed_at", "errors"); err != nil {
log.Errorf(ctx, "db error marking action %s as completed: %q", actionKey, err)
}
}()
return nil
}
// GetRunning sounds like a threat, but it actually just
// returns all of the currently running actions held by
// the Actions struct, ordered by ID descending.
func (a *Actions) GetRunning() []*gtsmodel.AdminAction {
a.m.Lock()
defer a.m.Unlock()
// Assemble all currently running actions.
running := make([]*gtsmodel.AdminAction, 0, len(a.r))
for _, action := range a.r {
running = append(running, action)
}
// Order by ID descending (creation date).
slices.SortFunc(
running,
func(a *gtsmodel.AdminAction, b *gtsmodel.AdminAction) int {
const k = -1
switch {
case a.ID > b.ID:
return +k
case a.ID < b.ID:
return -k
default:
return 0
}
},
)
return running
}
// TotalRunning is a sequel to the classic
// 1972 environmental-themed science fiction
// film Silent Running, starring Bruce Dern.
func (a *Actions) TotalRunning() int {
a.m.Lock()
defer a.m.Unlock()
return len(a.r)
}

View file

@ -1,162 +0,0 @@
// 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 admin_test
import (
"context"
"errors"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/testrig"
)
type ActionsTestSuite struct {
AdminStandardTestSuite
}
func (suite *ActionsTestSuite) TestActionOverlap() {
ctx := context.Background()
// Suspend account.
action1 := &gtsmodel.AdminAction{
ID: id.NewULID(),
TargetCategory: gtsmodel.AdminActionCategoryAccount,
TargetID: "01H90S1CXQ97J9625C5YBXZWGT",
Type: gtsmodel.AdminActionSuspend,
AccountID: "01H90S1ZZXP4N74H4A9RVW1MRP",
}
key1 := action1.Key()
suite.Equal("account/01H90S1CXQ97J9625C5YBXZWGT", key1)
// Unsuspend account.
action2 := &gtsmodel.AdminAction{
ID: id.NewULID(),
TargetCategory: gtsmodel.AdminActionCategoryAccount,
TargetID: "01H90S1CXQ97J9625C5YBXZWGT",
Type: gtsmodel.AdminActionUnsuspend,
AccountID: "01H90S1ZZXP4N74H4A9RVW1MRP",
}
key2 := action2.Key()
suite.Equal("account/01H90S1CXQ97J9625C5YBXZWGT", key2)
errWithCode := suite.adminProcessor.Actions().Run(
ctx,
action1,
func(ctx context.Context) gtserror.MultiError {
// Noop, just sleep (mood).
time.Sleep(3 * time.Second)
return nil
},
)
suite.NoError(errWithCode)
// While first action is sleeping, try to
// process another with the same key.
errWithCode = suite.adminProcessor.Actions().Run(
ctx,
action2,
func(ctx context.Context) gtserror.MultiError {
return nil
},
)
if errWithCode == nil {
suite.FailNow("expected error with code, but error was nil")
}
// Code should be 409.
suite.Equal(http.StatusConflict, errWithCode.Code())
// Wait for action to finish.
if !testrig.WaitFor(func() bool {
return suite.adminProcessor.Actions().TotalRunning() == 0
}) {
suite.FailNow("timed out waiting for admin action(s) to finish")
}
// Try again.
errWithCode = suite.adminProcessor.Actions().Run(
ctx,
action2,
func(ctx context.Context) gtserror.MultiError {
return nil
},
)
suite.NoError(errWithCode)
// Wait for action to finish.
if !testrig.WaitFor(func() bool {
return suite.adminProcessor.Actions().TotalRunning() == 0
}) {
suite.FailNow("timed out waiting for admin action(s) to finish")
}
}
func (suite *ActionsTestSuite) TestActionWithErrors() {
ctx := context.Background()
// Suspend a domain.
action := &gtsmodel.AdminAction{
ID: id.NewULID(),
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: "example.org",
Type: gtsmodel.AdminActionSuspend,
AccountID: "01H90S1ZZXP4N74H4A9RVW1MRP",
}
errWithCode := suite.adminProcessor.Actions().Run(
ctx,
action,
func(ctx context.Context) gtserror.MultiError {
// Noop, just return some errs.
return gtserror.MultiError{
db.ErrNoEntries,
errors.New("fucky wucky"),
}
},
)
suite.NoError(errWithCode)
// Wait for action to finish.
if !testrig.WaitFor(func() bool {
return suite.adminProcessor.Actions().TotalRunning() == 0
}) {
suite.FailNow("timed out waiting for admin action(s) to finish")
}
// Get action from the db.
dbAction, err := suite.db.GetAdminAction(ctx, action.ID)
if err != nil {
suite.FailNow(err.Error())
}
suite.EqualValues([]string{
"sql: no rows in result set",
"fucky wucky",
}, dbAction.Errors)
}
func TestActionsTestSuite(t *testing.T) {
suite.Run(t, new(ActionsTestSuite))
}

View file

@ -21,10 +21,10 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/cleaner"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/processing/common"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/subscriptions"
"github.com/superseriousbusiness/gotosocial/internal/transport"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
)
@ -33,21 +33,14 @@ type Processor struct {
// common processor logic
c *common.Processor
state *state.State
cleaner *cleaner.Cleaner
converter *typeutils.Converter
federator *federation.Federator
media *media.Manager
transport transport.Controller
email email.Sender
// admin Actions currently
// undergoing processing
actions *Actions
}
func (p *Processor) Actions() *Actions {
return p.actions
state *state.State
cleaner *cleaner.Cleaner
subscriptions *subscriptions.Subscriptions
converter *typeutils.Converter
federator *federation.Federator
media *media.Manager
transport transport.Controller
email email.Sender
}
// New returns a new admin processor.
@ -55,6 +48,7 @@ func New(
common *common.Processor,
state *state.State,
cleaner *cleaner.Cleaner,
subscriptions *subscriptions.Subscriptions,
federator *federation.Federator,
converter *typeutils.Converter,
mediaManager *media.Manager,
@ -62,17 +56,14 @@ func New(
emailSender email.Sender,
) Processor {
return Processor{
c: common,
state: state,
cleaner: cleaner,
converter: converter,
federator: federator,
media: mediaManager,
transport: transportController,
email: emailSender,
actions: &Actions{
r: make(map[string]*gtsmodel.AdminAction),
state: state,
},
c: common,
state: state,
cleaner: cleaner,
subscriptions: subscriptions,
converter: converter,
federator: federator,
media: mediaManager,
transport: transportController,
email: emailSender,
}
}

View file

@ -19,6 +19,7 @@ package admin_test
import (
"github.com/stretchr/testify/suite"
adminactions "github.com/superseriousbusiness/gotosocial/internal/admin"
"github.com/superseriousbusiness/gotosocial/internal/cleaner"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/email"
@ -33,6 +34,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/processing/admin"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/internal/subscriptions"
"github.com/superseriousbusiness/gotosocial/internal/transport"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/testrig"
@ -89,6 +91,7 @@ func (suite *AdminStandardTestSuite) SetupTest() {
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.state.AdminActions = adminactions.New(suite.state.DB, &suite.state.Workers)
suite.tc = typeutils.NewConverter(&suite.state)
testrig.StartTimelines(
@ -109,6 +112,7 @@ func (suite *AdminStandardTestSuite) SetupTest() {
suite.processor = processing.NewProcessor(
cleaner.New(&suite.state),
subscriptions.New(&suite.state, suite.transportController, suite.tc),
suite.tc,
suite.federator,
suite.oauthServer,

View file

@ -22,14 +22,11 @@ import (
"errors"
"fmt"
"codeberg.org/gruf/go-kv"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/text"
)
@ -69,84 +66,30 @@ func (p *Processor) createDomainAllow(
}
}
actionID := id.NewULID()
// Run admin action to process
// side effects of allow.
action := &gtsmodel.AdminAction{
ID: id.NewULID(),
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domainAllow.Domain,
Type: gtsmodel.AdminActionUnsuspend,
AccountID: adminAcct.ID,
}
// Process domain allow side
// effects asynchronously.
if errWithCode := p.actions.Run(
if errWithCode := p.state.AdminActions.Run(
ctx,
&gtsmodel.AdminAction{
ID: actionID,
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domain,
Type: gtsmodel.AdminActionSuspend,
AccountID: adminAcct.ID,
Text: domainAllow.PrivateComment,
},
func(ctx context.Context) gtserror.MultiError {
// Log start + finish.
l := log.WithFields(kv.Fields{
{"domain", domain},
{"actionID", actionID},
}...).WithContext(ctx)
l.Info("processing domain allow side effects")
defer func() { l.Info("finished processing domain allow side effects") }()
return p.domainAllowSideEffects(ctx, domainAllow)
},
action,
p.state.AdminActions.DomainAllowF(action.ID, domainAllow),
); errWithCode != nil {
return nil, actionID, errWithCode
return nil, action.ID, errWithCode
}
apiDomainAllow, errWithCode := p.apiDomainPerm(ctx, domainAllow, false)
if errWithCode != nil {
return nil, actionID, errWithCode
return nil, action.ID, errWithCode
}
return apiDomainAllow, actionID, nil
}
func (p *Processor) domainAllowSideEffects(
ctx context.Context,
allow *gtsmodel.DomainAllow,
) gtserror.MultiError {
if config.GetInstanceFederationMode() == config.InstanceFederationModeAllowlist {
// We're running in allowlist mode,
// so there are no side effects to
// process here.
return nil
}
// We're running in blocklist mode or
// some similar mode which necessitates
// domain allow side effects if a block
// was in place when the allow was created.
//
// So, check if there's a block.
block, err := p.state.DB.GetDomainBlock(ctx, allow.Domain)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs := gtserror.NewMultiError(1)
errs.Appendf("db error getting domain block %s: %w", allow.Domain, err)
return errs
}
if block == nil {
// No block?
// No problem!
return nil
}
// There was a block, over which the new
// allow ought to take precedence. To account
// for this, just run side effects as though
// the domain was being unblocked, while
// leaving the existing block in place.
//
// Any accounts that were suspended by
// the block will be unsuspended and be
// able to interact with the instance again.
return p.domainUnblockSideEffects(ctx, block)
return apiDomainAllow, action.ID, nil
}
func (p *Processor) deleteDomainAllow(
@ -179,77 +122,23 @@ func (p *Processor) deleteDomainAllow(
return nil, "", gtserror.NewErrorInternalError(err)
}
actionID := id.NewULID()
// Run admin action to process
// side effects of unallow.
action := &gtsmodel.AdminAction{
ID: id.NewULID(),
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domainAllow.Domain,
Type: gtsmodel.AdminActionUnsuspend,
AccountID: adminAcct.ID,
}
// Process domain unallow side
// effects asynchronously.
if errWithCode := p.actions.Run(
if errWithCode := p.state.AdminActions.Run(
ctx,
&gtsmodel.AdminAction{
ID: actionID,
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domainAllow.Domain,
Type: gtsmodel.AdminActionUnsuspend,
AccountID: adminAcct.ID,
},
func(ctx context.Context) gtserror.MultiError {
// Log start + finish.
l := log.WithFields(kv.Fields{
{"domain", domainAllow.Domain},
{"actionID", actionID},
}...).WithContext(ctx)
l.Info("processing domain unallow side effects")
defer func() { l.Info("finished processing domain unallow side effects") }()
return p.domainUnallowSideEffects(ctx, domainAllow)
},
action,
p.state.AdminActions.DomainUnallowF(action.ID, domainAllow),
); errWithCode != nil {
return nil, actionID, errWithCode
return nil, action.ID, errWithCode
}
return apiDomainAllow, actionID, nil
}
func (p *Processor) domainUnallowSideEffects(
ctx context.Context,
allow *gtsmodel.DomainAllow,
) gtserror.MultiError {
if config.GetInstanceFederationMode() == config.InstanceFederationModeAllowlist {
// We're running in allowlist mode,
// so there are no side effects to
// process here.
return nil
}
// We're running in blocklist mode or
// some similar mode which necessitates
// domain allow side effects if a block
// was in place when the allow was removed.
//
// So, check if there's a block.
block, err := p.state.DB.GetDomainBlock(ctx, allow.Domain)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs := gtserror.NewMultiError(1)
errs.Appendf("db error getting domain block %s: %w", allow.Domain, err)
return errs
}
if block == nil {
// No block?
// No problem!
return nil
}
// There was a block, over which the previous
// allow was taking precedence. Now that the
// allow has been removed, we should put the
// side effects of the block back in place.
//
// To do this, process the block side effects
// again as though the block were freshly
// created. This will mark all accounts from
// the blocked domain as suspended, and clean
// up their follows/following, media, etc.
return p.domainBlockSideEffects(ctx, block)
return apiDomainAllow, action.ID, nil
}

View file

@ -21,18 +21,12 @@ import (
"context"
"errors"
"fmt"
"time"
"codeberg.org/gruf/go-kv"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/text"
)
@ -72,149 +66,31 @@ func (p *Processor) createDomainBlock(
}
}
actionID := id.NewULID()
// Run admin action to process
// side effects of block.
action := &gtsmodel.AdminAction{
ID: id.NewULID(),
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domain,
Type: gtsmodel.AdminActionSuspend,
AccountID: adminAcct.ID,
Text: domainBlock.PrivateComment,
}
// Process domain block side
// effects asynchronously.
if errWithCode := p.actions.Run(
if errWithCode := p.state.AdminActions.Run(
ctx,
&gtsmodel.AdminAction{
ID: actionID,
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domain,
Type: gtsmodel.AdminActionSuspend,
AccountID: adminAcct.ID,
Text: domainBlock.PrivateComment,
},
func(ctx context.Context) gtserror.MultiError {
// Log start + finish.
l := log.WithFields(kv.Fields{
{"domain", domain},
{"actionID", actionID},
}...).WithContext(ctx)
skip, err := p.skipBlockSideEffects(ctx, domain)
if err != nil {
return err
}
if skip != "" {
l.Infof("skipping domain block side effects: %s", skip)
return nil
}
l.Info("processing domain block side effects")
defer func() { l.Info("finished processing domain block side effects") }()
return p.domainBlockSideEffects(ctx, domainBlock)
},
action,
p.state.AdminActions.DomainBlockF(action.ID, domainBlock),
); errWithCode != nil {
return nil, actionID, errWithCode
return nil, action.ID, errWithCode
}
apiDomainBlock, errWithCode := p.apiDomainPerm(ctx, domainBlock, false)
if errWithCode != nil {
return nil, actionID, errWithCode
return nil, action.ID, errWithCode
}
return apiDomainBlock, actionID, nil
}
// skipBlockSideEffects checks if side effects of block creation
// should be skipped for the given domain, taking account of
// instance federation mode, and existence of any allows
// which ought to "shield" this domain from being blocked.
//
// If the caller should skip, the returned string will be non-zero
// and will be set to a reason why side effects should be skipped.
//
// - blocklist mode + allow exists: "..." (skip)
// - blocklist mode + no allow: "" (don't skip)
// - allowlist mode + allow exists: "" (don't skip)
// - allowlist mode + no allow: "" (don't skip)
func (p *Processor) skipBlockSideEffects(
ctx context.Context,
domain string,
) (string, gtserror.MultiError) {
var (
skip string // Assume "" (don't skip).
errs gtserror.MultiError
)
// Never skip block side effects in allowlist mode.
fediMode := config.GetInstanceFederationMode()
if fediMode == config.InstanceFederationModeAllowlist {
return skip, errs
}
// We know we're in blocklist mode.
//
// We want to skip domain block side
// effects if an allow is already
// in place which overrides the block.
// Check if an explicit allow exists for this domain.
domainAllow, err := p.state.DB.GetDomainAllow(ctx, domain)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs.Appendf("error getting domain allow: %w", err)
return skip, errs
}
if domainAllow != nil {
skip = "running in blocklist mode, and an explicit allow exists for this domain"
return skip, errs
}
return skip, errs
}
// domainBlockSideEffects processes the side effects of a domain block:
//
// 1. Strip most info away from the instance entry for the domain.
// 2. Pass each account from the domain to the processor for deletion.
//
// It should be called asynchronously, since it can take a while when
// there are many accounts present on the given domain.
func (p *Processor) domainBlockSideEffects(
ctx context.Context,
block *gtsmodel.DomainBlock,
) gtserror.MultiError {
var errs gtserror.MultiError
// If we have an instance entry for this domain,
// update it with the new block ID and clear all fields
instance, err := p.state.DB.GetInstance(ctx, block.Domain)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs.Appendf("db error getting instance %s: %w", block.Domain, err)
return errs
}
if instance != nil {
// We had an entry for this domain.
columns := stubbifyInstance(instance, block.ID)
if err := p.state.DB.UpdateInstance(ctx, instance, columns...); err != nil {
errs.Appendf("db error updating instance: %w", err)
return errs
}
}
// For each account that belongs to this domain,
// process an account delete message to remove
// that account's posts, media, etc.
if err := p.rangeDomainAccounts(ctx, block.Domain, func(account *gtsmodel.Account) {
if err := p.state.Workers.Client.Process(ctx, &messages.FromClientAPI{
APObjectType: ap.ActorPerson,
APActivityType: ap.ActivityDelete,
GTSModel: block,
Origin: account,
Target: account,
}); err != nil {
errs.Append(err)
}
}); err != nil {
errs.Appendf("db error ranging through accounts: %w", err)
}
return errs
return apiDomainBlock, action.ID, nil
}
func (p *Processor) deleteDomainBlock(
@ -247,104 +123,23 @@ func (p *Processor) deleteDomainBlock(
return nil, "", gtserror.NewErrorInternalError(err)
}
actionID := id.NewULID()
// Run admin action to process
// side effects of unblock.
action := &gtsmodel.AdminAction{
ID: id.NewULID(),
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domainBlock.Domain,
Type: gtsmodel.AdminActionUnsuspend,
AccountID: adminAcct.ID,
}
// Process domain unblock side
// effects asynchronously.
if errWithCode := p.actions.Run(
if errWithCode := p.state.AdminActions.Run(
ctx,
&gtsmodel.AdminAction{
ID: actionID,
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domainBlock.Domain,
Type: gtsmodel.AdminActionUnsuspend,
AccountID: adminAcct.ID,
},
func(ctx context.Context) gtserror.MultiError {
// Log start + finish.
l := log.WithFields(kv.Fields{
{"domain", domainBlock.Domain},
{"actionID", actionID},
}...).WithContext(ctx)
l.Info("processing domain unblock side effects")
defer func() { l.Info("finished processing domain unblock side effects") }()
return p.domainUnblockSideEffects(ctx, domainBlock)
},
action,
p.state.AdminActions.DomainUnblockF(action.ID, domainBlock),
); errWithCode != nil {
return nil, actionID, errWithCode
return nil, action.ID, errWithCode
}
return apiDomainBlock, actionID, nil
}
// domainUnblockSideEffects processes the side effects of undoing a
// domain block:
//
// 1. Mark instance entry as no longer suspended.
// 2. Mark each account from the domain as no longer suspended, if the
// suspension origin corresponds to the ID of the provided domain block.
//
// It should be called asynchronously, since it can take a while when
// there are many accounts present on the given domain.
func (p *Processor) domainUnblockSideEffects(
ctx context.Context,
block *gtsmodel.DomainBlock,
) gtserror.MultiError {
var errs gtserror.MultiError
// Update instance entry for this domain, if we have it.
instance, err := p.state.DB.GetInstance(ctx, block.Domain)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
errs.Appendf("db error getting instance %s: %w", block.Domain, err)
}
if instance != nil {
// We had an entry, update it to signal
// that it's no longer suspended.
instance.SuspendedAt = time.Time{}
instance.DomainBlockID = ""
if err := p.state.DB.UpdateInstance(
ctx,
instance,
"suspended_at",
"domain_block_id",
); err != nil {
errs.Appendf("db error updating instance: %w", err)
return errs
}
}
// Unsuspend all accounts whose suspension origin was this domain block.
if err := p.rangeDomainAccounts(ctx, block.Domain, func(account *gtsmodel.Account) {
if account.SuspensionOrigin == "" || account.SuspendedAt.IsZero() {
// Account wasn't suspended, nothing to do.
return
}
if account.SuspensionOrigin != block.ID {
// Account was suspended, but not by
// this domain block, leave it alone.
return
}
// Account was suspended by this domain
// block, mark it as unsuspended.
account.SuspendedAt = time.Time{}
account.SuspensionOrigin = ""
if err := p.state.DB.UpdateAccount(
ctx,
account,
"suspended_at",
"suspension_origin",
); err != nil {
errs.Appendf("db error updating account %s: %w", account.Username, err)
}
}); err != nil {
errs.Appendf("db error ranging through accounts: %w", err)
}
return errs
return apiDomainBlock, action.ID, nil
}

View file

@ -19,7 +19,6 @@ package admin
import (
"context"
"time"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -39,47 +38,23 @@ func (p *Processor) DomainKeysExpire(
adminAcct *gtsmodel.Account,
domain string,
) (string, gtserror.WithCode) {
actionID := id.NewULID()
// Run admin action to process
// side effects of key expiry.
action := &gtsmodel.AdminAction{
ID: id.NewULID(),
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domain,
Type: gtsmodel.AdminActionExpireKeys,
AccountID: adminAcct.ID,
}
// Process key expiration asynchronously.
if errWithCode := p.actions.Run(
if errWithCode := p.state.AdminActions.Run(
ctx,
&gtsmodel.AdminAction{
ID: actionID,
TargetCategory: gtsmodel.AdminActionCategoryDomain,
TargetID: domain,
Type: gtsmodel.AdminActionExpireKeys,
AccountID: adminAcct.ID,
},
func(ctx context.Context) gtserror.MultiError {
return p.domainKeysExpireSideEffects(ctx, domain)
},
action,
p.state.AdminActions.DomainKeysExpireF(domain),
); errWithCode != nil {
return actionID, errWithCode
return action.ID, errWithCode
}
return actionID, nil
}
func (p *Processor) domainKeysExpireSideEffects(ctx context.Context, domain string) gtserror.MultiError {
var (
expiresAt = time.Now()
errs gtserror.MultiError
)
// For each account on this domain, expire
// the public key and update the account.
if err := p.rangeDomainAccounts(ctx, domain, func(account *gtsmodel.Account) {
account.PublicKeyExpiresAt = expiresAt
if err := p.state.DB.UpdateAccount(ctx,
account,
"public_key_expires_at",
); err != nil {
errs.Appendf("db error updating account: %w", err)
}
}); err != nil {
errs.Appendf("db error ranging through accounts: %w", err)
}
return errs
return action.ID, nil
}

View file

@ -31,24 +31,6 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// apiDomainPerm is a cheeky shortcut for returning
// the API version of the given domain permission
// (*gtsmodel.DomainBlock or *gtsmodel.DomainAllow),
// or an appropriate error if something goes wrong.
func (p *Processor) apiDomainPerm(
ctx context.Context,
domainPermission gtsmodel.DomainPermission,
export bool,
) (*apimodel.DomainPermission, gtserror.WithCode) {
apiDomainPerm, err := p.converter.DomainPermToAPIDomainPerm(ctx, domainPermission, export)
if err != nil {
err := gtserror.NewfAt(3, "error converting domain permission to api model: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiDomainPerm, nil
}
// DomainPermissionCreate creates an instance-level permission
// targeting the given domain, and then processes any side
// effects of the permission creation.

View file

@ -186,7 +186,7 @@ func (suite *DomainBlockTestSuite) awaitAction(actionID string) {
ctx := context.Background()
if !testrig.WaitFor(func() bool {
return suite.adminProcessor.Actions().TotalRunning() == 0
return suite.state.AdminActions.TotalRunning() == 0
}) {
suite.FailNow("timed out waiting for admin action(s) to finish")
}

View file

@ -0,0 +1,324 @@
// 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 admin
import (
"context"
"errors"
"fmt"
"net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"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"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// DomainPermissionDraftGet returns one
// domain permission draft with the given id.
func (p *Processor) DomainPermissionDraftGet(
ctx context.Context,
id string,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permDraft, err := p.state.DB.GetDomainPermissionDraftByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission draft %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permDraft == nil {
err := fmt.Errorf("domain permission draft %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
return p.apiDomainPerm(ctx, permDraft, false)
}
// DomainPermissionDraftsGet returns a page of
// DomainPermissionDrafts with the given parameters.
func (p *Processor) DomainPermissionDraftsGet(
ctx context.Context,
subscriptionID string,
domain string,
permType gtsmodel.DomainPermissionType,
page *paging.Page,
) (*apimodel.PageableResponse, gtserror.WithCode) {
permDrafts, err := p.state.DB.GetDomainPermissionDrafts(
ctx,
permType,
subscriptionID,
domain,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
count := len(permDrafts)
if count == 0 {
return paging.EmptyResponse(), nil
}
// Get the lowest and highest
// ID values, used for paging.
lo := permDrafts[count-1].ID
hi := permDrafts[0].ID
// Convert each perm draft to API model.
items := make([]any, len(permDrafts))
for i, permDraft := range permDrafts {
apiPermDraft, err := p.apiDomainPerm(ctx, permDraft, false)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
items[i] = apiPermDraft
}
// Assemble next/prev page queries.
query := make(url.Values, 3)
if subscriptionID != "" {
query.Set(apiutil.DomainPermissionSubscriptionIDKey, subscriptionID)
}
if domain != "" {
query.Set(apiutil.DomainPermissionDomainKey, domain)
}
if permType != gtsmodel.DomainPermissionUnknown {
query.Set(apiutil.DomainPermissionPermTypeKey, permType.String())
}
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/admin/domain_permission_drafts",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: query,
}), nil
}
func (p *Processor) DomainPermissionDraftCreate(
ctx context.Context,
acct *gtsmodel.Account,
domain string,
permType gtsmodel.DomainPermissionType,
obfuscate bool,
publicComment string,
privateComment string,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permDraft := &gtsmodel.DomainPermissionDraft{
ID: id.NewULID(),
PermissionType: permType,
Domain: domain,
CreatedByAccountID: acct.ID,
CreatedByAccount: acct,
PrivateComment: privateComment,
PublicComment: publicComment,
Obfuscate: &obfuscate,
}
if err := p.state.DB.PutDomainPermissionDraft(ctx, permDraft); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
const text = "a domain permission draft already exists with this permission type, domain, and subscription ID"
err := fmt.Errorf("%w: %s", err, text)
return nil, gtserror.NewErrorConflict(err, text)
}
// Real error.
err := gtserror.Newf("db error putting domain permission draft: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return p.apiDomainPerm(ctx, permDraft, false)
}
func (p *Processor) DomainPermissionDraftAccept(
ctx context.Context,
acct *gtsmodel.Account,
id string,
overwrite bool,
) (*apimodel.DomainPermission, string, gtserror.WithCode) {
permDraft, err := p.state.DB.GetDomainPermissionDraftByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission draft %s: %w", id, err)
return nil, "", gtserror.NewErrorInternalError(err)
}
if permDraft == nil {
err := fmt.Errorf("domain permission draft %s not found", id)
return nil, "", gtserror.NewErrorNotFound(err, err.Error())
}
var (
// Existing permission
// entry, if it exists.
existing gtsmodel.DomainPermission
)
// Try to get existing entry.
switch permDraft.PermissionType {
case gtsmodel.DomainPermissionBlock:
existing, err = p.state.DB.GetDomainBlock(
gtscontext.SetBarebones(ctx),
permDraft.Domain,
)
case gtsmodel.DomainPermissionAllow:
existing, err = p.state.DB.GetDomainAllow(
gtscontext.SetBarebones(ctx),
permDraft.Domain,
)
}
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission %s: %w", id, err)
return nil, "", gtserror.NewErrorInternalError(err)
}
// Check if we got existing entry.
existed := !util.IsNil(existing)
if existed && !overwrite {
// Domain permission exists and we shouldn't
// overwrite it, leave everything alone.
const text = "a domain permission already exists with this permission type and domain"
return nil, "", gtserror.NewErrorConflict(errors.New(text), text)
}
// Function to clean up the accepted draft, only called if
// creating or updating permission from draft is successful.
deleteDraft := func() {
if err := p.state.DB.DeleteDomainPermissionDraft(ctx, permDraft.ID); err != nil {
log.Errorf(ctx, "db error deleting domain permission draft: %v", err)
}
}
if !existed {
// Easy case, we just need to create a new domain
// permission from the draft, and then delete it.
var (
new *apimodel.DomainPermission
actionID string
errWithCode gtserror.WithCode
)
if permDraft.PermissionType == gtsmodel.DomainPermissionBlock {
new, actionID, errWithCode = p.createDomainBlock(
ctx,
acct,
permDraft.Domain,
*permDraft.Obfuscate,
permDraft.PublicComment,
permDraft.PrivateComment,
permDraft.SubscriptionID,
)
}
if permDraft.PermissionType == gtsmodel.DomainPermissionAllow {
new, actionID, errWithCode = p.createDomainAllow(
ctx,
acct,
permDraft.Domain,
*permDraft.Obfuscate,
permDraft.PublicComment,
permDraft.PrivateComment,
permDraft.SubscriptionID,
)
}
// Clean up the draft
// before returning.
deleteDraft()
return new, actionID, errWithCode
}
// Domain permission exists but we should overwrite
// it by just updating the existing domain permission.
// Domain can't change, so no need to re-run side effects.
existing.SetCreatedByAccountID(permDraft.CreatedByAccountID)
existing.SetCreatedByAccount(permDraft.CreatedByAccount)
existing.SetPrivateComment(permDraft.PrivateComment)
existing.SetPublicComment(permDraft.PublicComment)
existing.SetObfuscate(permDraft.Obfuscate)
existing.SetSubscriptionID(permDraft.SubscriptionID)
switch dp := existing.(type) {
case *gtsmodel.DomainBlock:
err = p.state.DB.UpdateDomainBlock(ctx, dp)
case *gtsmodel.DomainAllow:
err = p.state.DB.UpdateDomainAllow(ctx, dp)
}
if err != nil {
err := gtserror.Newf("db error updating existing domain permission: %w", err)
return nil, "", gtserror.NewErrorInternalError(err)
}
// Clean up the draft
// before returning.
deleteDraft()
apiPerm, errWithCode := p.apiDomainPerm(ctx, existing, false)
return apiPerm, "", errWithCode
}
func (p *Processor) DomainPermissionDraftRemove(
ctx context.Context,
acct *gtsmodel.Account,
id string,
excludeTarget bool,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permDraft, err := p.state.DB.GetDomainPermissionDraftByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission draft %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permDraft == nil {
err := fmt.Errorf("domain permission draft %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
// Delete the permission draft.
if err := p.state.DB.DeleteDomainPermissionDraft(ctx, permDraft.ID); err != nil {
err := gtserror.Newf("db error deleting domain permission draft: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if excludeTarget {
// Add a domain permission exclude
// targeting the permDraft's domain.
_, err = p.DomainPermissionExcludeCreate(
ctx,
acct,
permDraft.Domain,
permDraft.PrivateComment,
)
if err != nil && !errors.Is(err, db.ErrAlreadyExists) {
err := gtserror.Newf("db error creating domain permission exclude: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
}
return p.apiDomainPerm(ctx, permDraft, false)
}

View file

@ -0,0 +1,159 @@
// 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 admin
import (
"context"
"errors"
"fmt"
"net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
func (p *Processor) DomainPermissionExcludeCreate(
ctx context.Context,
acct *gtsmodel.Account,
domain string,
privateComment string,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permExclude := &gtsmodel.DomainPermissionExclude{
ID: id.NewULID(),
Domain: domain,
CreatedByAccountID: acct.ID,
CreatedByAccount: acct,
PrivateComment: privateComment,
}
if err := p.state.DB.PutDomainPermissionExclude(ctx, permExclude); err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
const text = "a domain permission exclude already exists with this permission type and domain"
err := fmt.Errorf("%w: %s", err, text)
return nil, gtserror.NewErrorConflict(err, text)
}
// Real error.
err := gtserror.Newf("db error putting domain permission exclude: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return p.apiDomainPerm(ctx, permExclude, false)
}
// DomainPermissionExcludeGet returns one
// domain permission exclude with the given id.
func (p *Processor) DomainPermissionExcludeGet(
ctx context.Context,
id string,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permExclude, err := p.state.DB.GetDomainPermissionExcludeByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission exclude %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permExclude == nil {
err := fmt.Errorf("domain permission exclude %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
return p.apiDomainPerm(ctx, permExclude, false)
}
// DomainPermissionExcludesGet returns a page of
// DomainPermissionExcludes with the given parameters.
func (p *Processor) DomainPermissionExcludesGet(
ctx context.Context,
domain string,
page *paging.Page,
) (*apimodel.PageableResponse, gtserror.WithCode) {
permExcludes, err := p.state.DB.GetDomainPermissionExcludes(
ctx,
domain,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
count := len(permExcludes)
if count == 0 {
return paging.EmptyResponse(), nil
}
// Get the lowest and highest
// ID values, used for paging.
lo := permExcludes[count-1].ID
hi := permExcludes[0].ID
// Convert each perm exclude to API model.
items := make([]any, len(permExcludes))
for i, permExclude := range permExcludes {
apiPermExclude, err := p.apiDomainPerm(ctx, permExclude, false)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
items[i] = apiPermExclude
}
// Assemble next/prev page queries.
query := make(url.Values, 1)
if domain != "" {
query.Set(apiutil.DomainPermissionDomainKey, domain)
}
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/admin/domain_permission_excludes",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: query,
}), nil
}
func (p *Processor) DomainPermissionExcludeRemove(
ctx context.Context,
acct *gtsmodel.Account,
id string,
) (*apimodel.DomainPermission, gtserror.WithCode) {
permExclude, err := p.state.DB.GetDomainPermissionExcludeByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission exclude %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permExclude == nil {
err := fmt.Errorf("domain permission exclude %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
// Delete the permission exclude.
if err := p.state.DB.DeleteDomainPermissionExclude(ctx, permExclude.ID); err != nil {
err := gtserror.Newf("db error deleting domain permission exclude: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return p.apiDomainPerm(ctx, permExclude, false)
}

View file

@ -0,0 +1,378 @@
// 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 admin
import (
"context"
"errors"
"fmt"
"net/url"
"slices"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/paging"
)
// DomainPermissionSubscriptionGet returns one
// domain permission subscription with the given id.
func (p *Processor) DomainPermissionSubscriptionGet(
ctx context.Context,
id string,
) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) {
permSub, err := p.state.DB.GetDomainPermissionSubscriptionByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission subscription %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permSub == nil {
err := fmt.Errorf("domain permission subscription %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
return p.apiDomainPermSub(ctx, permSub)
}
// DomainPermissionSubscriptionsGet returns a page of
// DomainPermissionSubscriptions with the given parameters.
func (p *Processor) DomainPermissionSubscriptionsGet(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
page *paging.Page,
) (*apimodel.PageableResponse, gtserror.WithCode) {
permSubs, err := p.state.DB.GetDomainPermissionSubscriptions(
ctx,
permType,
page,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
count := len(permSubs)
if count == 0 {
return paging.EmptyResponse(), nil
}
// Get the lowest and highest
// ID values, used for paging.
lo := permSubs[count-1].ID
hi := permSubs[0].ID
// Convert each perm sub to API model.
items := make([]any, len(permSubs))
for i, permSub := range permSubs {
apiPermSub, err := p.converter.DomainPermSubToAPIDomainPermSub(ctx, permSub)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
items[i] = apiPermSub
}
// Assemble next/prev page queries.
query := make(url.Values, 1)
if permType != gtsmodel.DomainPermissionUnknown {
query.Set(apiutil.DomainPermissionPermTypeKey, permType.String())
}
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/admin/domain_permission_subscriptions",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: query,
}), nil
}
// DomainPermissionSubscriptionsGetByPriority returns all domain permission
// subscriptions of the given permission type, in descending priority order.
func (p *Processor) DomainPermissionSubscriptionsGetByPriority(
ctx context.Context,
permType gtsmodel.DomainPermissionType,
) ([]*apimodel.DomainPermissionSubscription, gtserror.WithCode) {
permSubs, err := p.state.DB.GetDomainPermissionSubscriptionsByPriority(
ctx,
permType,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Convert each perm sub to API model.
items := make([]*apimodel.DomainPermissionSubscription, len(permSubs))
for i, permSub := range permSubs {
apiPermSub, err := p.converter.DomainPermSubToAPIDomainPermSub(ctx, permSub)
if err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
items[i] = apiPermSub
}
return items, nil
}
func (p *Processor) DomainPermissionSubscriptionCreate(
ctx context.Context,
acct *gtsmodel.Account,
priority uint8,
title string,
uri string,
contentType gtsmodel.DomainPermSubContentType,
permType gtsmodel.DomainPermissionType,
asDraft bool,
fetchUsername string,
fetchPassword string,
) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) {
permSub := &gtsmodel.DomainPermissionSubscription{
ID: id.NewULID(),
Priority: priority,
Title: title,
PermissionType: permType,
AsDraft: &asDraft,
CreatedByAccountID: acct.ID,
CreatedByAccount: acct,
URI: uri,
ContentType: contentType,
FetchUsername: fetchUsername,
FetchPassword: fetchPassword,
}
err := p.state.DB.PutDomainPermissionSubscription(ctx, permSub)
if err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
// Unique constraint conflict.
const errText = "domain permission subscription with given URI or title already exists"
return nil, gtserror.NewErrorConflict(errors.New(errText), errText)
}
// Real database error.
err := gtserror.Newf("db error putting domain permission subscription: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return p.apiDomainPermSub(ctx, permSub)
}
func (p *Processor) DomainPermissionSubscriptionUpdate(
ctx context.Context,
id string,
priority *uint8,
title *string,
uri *string,
contentType *gtsmodel.DomainPermSubContentType,
asDraft *bool,
adoptOrphans *bool,
fetchUsername *string,
fetchPassword *string,
) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) {
permSub, err := p.state.DB.GetDomainPermissionSubscriptionByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission subscription %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permSub == nil {
err := fmt.Errorf("domain permission subscription %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
columns := make([]string, 0, 7)
if priority != nil {
permSub.Priority = *priority
columns = append(columns, "priority")
}
if title != nil {
permSub.Title = *title
columns = append(columns, "title")
}
if uri != nil {
permSub.URI = *uri
columns = append(columns, "uri")
}
if contentType != nil {
permSub.ContentType = *contentType
columns = append(columns, "content_type")
}
if asDraft != nil {
permSub.AsDraft = asDraft
columns = append(columns, "as_draft")
}
if adoptOrphans != nil {
permSub.AdoptOrphans = adoptOrphans
columns = append(columns, "adopt_orphans")
}
if fetchPassword != nil {
permSub.FetchPassword = *fetchPassword
columns = append(columns, "fetch_password")
}
if fetchUsername != nil {
permSub.FetchUsername = *fetchUsername
columns = append(columns, "fetch_username")
}
err = p.state.DB.UpdateDomainPermissionSubscription(ctx, permSub, columns...)
if err != nil {
if errors.Is(err, db.ErrAlreadyExists) {
// Unique constraint conflict.
const errText = "domain permission subscription with given URI or title already exists"
return nil, gtserror.NewErrorConflict(errors.New(errText), errText)
}
// Real database error.
err := gtserror.Newf("db error updating domain permission subscription: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return p.apiDomainPermSub(ctx, permSub)
}
func (p *Processor) DomainPermissionSubscriptionRemove(
ctx context.Context,
id string,
removeChildren bool,
) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) {
permSub, err := p.state.DB.GetDomainPermissionSubscriptionByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission subscription %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permSub == nil {
err := fmt.Errorf("domain permission subscription %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
// Convert to API perm sub *before* doing the deletion.
apiPermSub, errWithCode := p.apiDomainPermSub(ctx, permSub)
if errWithCode != nil {
return nil, errWithCode
}
// TODO in next PR: if removeChildren, then remove all
// domain permissions that are children of this domain
// permission subscription. If not removeChildren, then
// just unlink them by clearing their subscription ID.
// For now just delete the domain permission subscription.
if err := p.state.DB.DeleteDomainPermissionSubscription(ctx, id); err != nil {
err := gtserror.Newf("db error deleting domain permission subscription: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiPermSub, nil
}
func (p *Processor) DomainPermissionSubscriptionTest(
ctx context.Context,
acct *gtsmodel.Account,
id string,
) (any, gtserror.WithCode) {
permSub, err := p.state.DB.GetDomainPermissionSubscriptionByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error getting domain permission subscription %s: %w", id, err)
return nil, gtserror.NewErrorInternalError(err)
}
if permSub == nil {
err := fmt.Errorf("domain permission subscription %s not found", id)
return nil, gtserror.NewErrorNotFound(err, err.Error())
}
// To process the test/dry-run correctly, we need to get
// all domain perm subs of this type with a *higher* priority,
// to know whether we ought to create permissions or not.
permSubs, err := p.state.DB.GetDomainPermissionSubscriptionsByPriority(
ctx,
permSub.PermissionType,
)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("db error: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Find the index of the targeted
// subscription in the slice.
index := slices.IndexFunc(
permSubs,
func(ps *gtsmodel.DomainPermissionSubscription) bool {
return ps.ID == permSub.ID
},
)
// Get a transport for calling permSub.URI.
tsport, err := p.transport.NewTransportForUsername(ctx, acct.Username)
if err != nil {
err := gtserror.Newf("error getting transport: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Everything *before* the targeted
// subscription has a higher priority.
higherPrios := permSubs[:index]
// Call the permSub.URI and parse a list of perms from it.
// Any error returned here is a "real" one, not an error
// from fetching / parsing the list.
createdPerms, err := p.subscriptions.ProcessDomainPermissionSubscription(
ctx,
permSub,
tsport,
higherPrios,
true, // Dry run.
)
if err != nil {
err := gtserror.Newf("error doing dry-run: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// If permSub has an error set on it now,
// we should return it to the caller.
if permSub.Error != "" {
return map[string]string{
"error": permSub.Error,
}, nil
}
// No error, so return the list of
// perms that would have been created.
apiPerms := make([]*apimodel.DomainPermission, 0, len(createdPerms))
for _, perm := range createdPerms {
apiPerm, errWithCode := p.apiDomainPerm(ctx, perm, false)
if errWithCode != nil {
return nil, errWithCode
}
apiPerms = append(apiPerms, apiPerm)
}
return apiPerms, nil
}

View file

@ -25,7 +25,6 @@ import (
"mime/multipart"
"strings"
"codeberg.org/gruf/go-bytesize"
"codeberg.org/gruf/go-iotools"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
@ -46,9 +45,10 @@ func (p *Processor) EmojiCreate(
// Get maximum supported local emoji size.
maxsz := config.GetMediaEmojiLocalMaxSize()
maxszInt64 := int64(maxsz) // #nosec G115 -- Already validated.
// Ensure media within size bounds.
if form.Image.Size > int64(maxsz) {
if form.Image.Size > maxszInt64 {
text := fmt.Sprintf("emoji exceeds configured max size: %s", maxsz)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
@ -61,7 +61,7 @@ func (p *Processor) EmojiCreate(
}
// Wrap the multipart file reader to ensure is limited to max.
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, int64(maxsz))
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, maxszInt64)
data := func(context.Context) (io.ReadCloser, error) {
return rc, nil
}
@ -301,9 +301,10 @@ func (p *Processor) emojiUpdateCopy(
// Get maximum supported local emoji size.
maxsz := config.GetMediaEmojiLocalMaxSize()
maxszInt := int(maxsz) // #nosec G115 -- Already validated.
// Ensure target emoji image within size bounds.
if bytesize.Size(target.ImageFileSize) > maxsz {
if target.ImageFileSize > maxszInt {
text := fmt.Sprintf("emoji exceeds configured max size: %s", maxsz)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
@ -442,9 +443,10 @@ func (p *Processor) emojiUpdateModify(
// Get maximum supported local emoji size.
maxsz := config.GetMediaEmojiLocalMaxSize()
maxszInt64 := int64(maxsz) // #nosec G115 -- Already validated.
// Ensure media within size bounds.
if image.Size > int64(maxsz) {
if image.Size > maxszInt64 {
text := fmt.Sprintf("emoji exceeds configured max size: %s", maxsz)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
@ -457,7 +459,7 @@ func (p *Processor) emojiUpdateModify(
}
// Wrap the multipart file reader to ensure is limited to max.
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, int64(maxsz))
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, int64(maxsz)) // #nosec G115 -- Already validated.
data := func(context.Context) (io.ReadCloser, error) {
return rc, nil
}

View file

@ -27,6 +27,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
@ -42,7 +43,7 @@ func (p *Processor) RulesGet(
apiRules := make([]*apimodel.AdminInstanceRule, len(rules))
for i := range rules {
apiRules[i] = p.converter.InstanceRuleToAdminAPIRule(&rules[i])
apiRules[i] = typeutils.InstanceRuleToAdminAPIRule(&rules[i])
}
return apiRules, nil
@ -58,7 +59,7 @@ func (p *Processor) RuleGet(ctx context.Context, id string) (*apimodel.AdminInst
return nil, gtserror.NewErrorInternalError(err)
}
return p.converter.InstanceRuleToAdminAPIRule(rule), nil
return typeutils.InstanceRuleToAdminAPIRule(rule), nil
}
// RuleCreate adds a new rule to the instance.
@ -77,7 +78,7 @@ func (p *Processor) RuleCreate(ctx context.Context, form *apimodel.InstanceRuleC
return nil, gtserror.NewErrorInternalError(err)
}
return p.converter.InstanceRuleToAdminAPIRule(rule), nil
return typeutils.InstanceRuleToAdminAPIRule(rule), nil
}
// RuleUpdate updates text for an existing rule.
@ -99,7 +100,7 @@ func (p *Processor) RuleUpdate(ctx context.Context, id string, form *apimodel.In
return nil, gtserror.NewErrorInternalError(err)
}
return p.converter.InstanceRuleToAdminAPIRule(updatedRule), nil
return typeutils.InstanceRuleToAdminAPIRule(updatedRule), nil
}
// RuleDelete deletes an existing rule.
@ -120,5 +121,5 @@ func (p *Processor) RuleDelete(ctx context.Context, id string) (*apimodel.AdminI
return nil, gtserror.NewErrorInternalError(err)
}
return p.converter.InstanceRuleToAdminAPIRule(deletedRule), nil
return typeutils.InstanceRuleToAdminAPIRule(deletedRule), nil
}

View file

@ -19,81 +19,41 @@ package admin
import (
"context"
"errors"
"time"
"github.com/superseriousbusiness/gotosocial/internal/db"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
)
// stubbifyInstance renders the given instance as a stub,
// removing most information from it and marking it as
// suspended.
//
// For caller's convenience, this function returns the db
// names of all columns that are updated by it.
func stubbifyInstance(instance *gtsmodel.Instance, domainBlockID string) []string {
instance.Title = ""
instance.SuspendedAt = time.Now()
instance.DomainBlockID = domainBlockID
instance.ShortDescription = ""
instance.Description = ""
instance.Terms = ""
instance.ContactEmail = ""
instance.ContactAccountUsername = ""
instance.ContactAccountID = ""
instance.Version = ""
return []string{
"title",
"suspended_at",
"domain_block_id",
"short_description",
"description",
"terms",
"contact_email",
"contact_account_username",
"contact_account_id",
"version",
}
}
// rangeDomainAccounts iterates through all accounts
// originating from the given domain, and calls the
// provided range function on each account.
//
// If an error is returned while selecting accounts,
// the loop will stop and return the error.
func (p *Processor) rangeDomainAccounts(
// apiDomainPerm is a cheeky shortcut for returning
// the API version of the given domain permission,
// or an appropriate error if something goes wrong.
func (p *Processor) apiDomainPerm(
ctx context.Context,
domain string,
rangeF func(*gtsmodel.Account),
) error {
var (
limit = 50 // Limit selection to avoid spiking mem/cpu.
maxID string // Start with empty string to select from top.
)
for {
// Get (next) page of accounts.
accounts, err := p.state.DB.GetInstanceAccounts(ctx, domain, maxID, limit)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
// Real db error.
return gtserror.Newf("db error getting instance accounts: %w", err)
}
if len(accounts) == 0 {
// No accounts left, we're done.
return nil
}
// Set next max ID for paging down.
maxID = accounts[len(accounts)-1].ID
// Call provided range function.
for _, account := range accounts {
rangeF(account)
}
domainPermission gtsmodel.DomainPermission,
export bool,
) (*apimodel.DomainPermission, gtserror.WithCode) {
apiDomainPerm, err := p.converter.DomainPermToAPIDomainPerm(ctx, domainPermission, export)
if err != nil {
err := gtserror.NewfAt(3, "error converting domain permission to api model: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiDomainPerm, nil
}
// apiDomainPermSub is a cheeky shortcut for returning the
// API version of the given domain permission subscription,
// or an appropriate error if something goes wrong.
func (p *Processor) apiDomainPermSub(
ctx context.Context,
domainPermSub *gtsmodel.DomainPermissionSubscription,
) (*apimodel.DomainPermissionSubscription, gtserror.WithCode) {
apiDomainPermSub, err := p.converter.DomainPermSubToAPIDomainPermSub(ctx, domainPermSub)
if err != nil {
err := gtserror.NewfAt(3, "error converting domain permission subscription to api model: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiDomainPermSub, nil
}

View file

@ -22,6 +22,7 @@ import (
"errors"
"fmt"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/media"
@ -51,11 +52,18 @@ func (p *Processor) StoreLocalMedia(
// Immediately trigger write to storage.
attachment, err := processing.Load(ctx)
if err != nil {
const text = "error processing emoji"
switch {
case gtserror.LimitReached(err):
limit := config.GetMediaLocalMaxSize()
text := fmt.Sprintf("local media size limit reached: %s", limit)
return nil, gtserror.NewErrorUnprocessableEntity(err, text)
case err != nil:
const text = "error processing media"
err := gtserror.Newf("error processing media: %w", err)
return nil, gtserror.NewErrorUnprocessableEntity(err, text)
} else if attachment.Type == gtsmodel.FileTypeUnknown {
case attachment.Type == gtsmodel.FileTypeUnknown:
text := fmt.Sprintf("could not process %s type media", attachment.File.ContentType)
return nil, gtserror.NewErrorUnprocessableEntity(errors.New(text), text)
}
@ -86,9 +94,15 @@ func (p *Processor) StoreLocalEmoji(
return nil, gtserror.NewErrorInternalError(err)
}
// Immediately write to storage.
// Immediately trigger write to storage.
emoji, err := processing.Load(ctx)
if err != nil {
switch {
case gtserror.LimitReached(err):
limit := config.GetMediaEmojiLocalMaxSize()
text := fmt.Sprintf("local emoji size limit reached: %s", limit)
return nil, gtserror.NewErrorUnprocessableEntity(err, text)
case err != nil:
const text = "error processing emoji"
err := gtserror.Newf("error processing emoji %s: %w", shortcode, err)
return nil, gtserror.NewErrorUnprocessableEntity(err, text)

View file

@ -31,6 +31,40 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/log"
)
// GetOwnStatus fetches the given status with ID,
// and ensures that it belongs to given requester.
func (p *Processor) GetOwnStatus(
ctx context.Context,
requester *gtsmodel.Account,
targetID string,
) (
*gtsmodel.Status,
gtserror.WithCode,
) {
target, err := p.state.DB.GetStatusByID(ctx, targetID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error getting from db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
switch {
case target == nil:
const text = "target status not found"
return nil, gtserror.NewErrorNotFound(
errors.New(text),
text,
)
case target.AccountID != requester.ID:
return nil, gtserror.NewErrorNotFound(
errors.New("status does not belong to requester"),
"target status not found",
)
}
return target, nil
}
// GetTargetStatusBy fetches the target status with db load
// function, given the authorized (or, nil) requester's
// account. This returns an approprate gtserror.WithCode
@ -247,6 +281,12 @@ func (p *Processor) GetVisibleAPIStatuses(
continue
}
if apiStatus == nil {
// Status was
// filtered out.
continue
}
// Append converted status to return slice.
apiStatuses = append(apiStatuses, *apiStatus)
}

View file

@ -23,6 +23,7 @@ import (
"time"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/admin"
"github.com/superseriousbusiness/gotosocial/internal/db"
dbtest "github.com/superseriousbusiness/gotosocial/internal/db/test"
"github.com/superseriousbusiness/gotosocial/internal/email"
@ -103,6 +104,7 @@ func (suite *ConversationsTestSuite) SetupTest() {
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
suite.tc = typeutils.NewConverter(&suite.state)
suite.filter = visibility.NewFilter(&suite.state)

View file

@ -43,7 +43,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
if *form.Irreversible {
filter.Action = gtsmodel.FilterActionHide
}
if form.ExpiresIn != nil {
if form.ExpiresIn != nil && *form.ExpiresIn != 0 {
filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
}
for _, context := range form.Context {

View file

@ -67,7 +67,7 @@ func (p *Processor) Update(
action = gtsmodel.FilterActionHide
}
expiresAt := time.Time{}
if form.ExpiresIn != nil {
if form.ExpiresIn != nil && *form.ExpiresIn != 0 {
expiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
}
contextHome := false

View file

@ -41,7 +41,7 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
Title: form.Title,
Action: typeutils.APIFilterActionToFilterAction(*form.FilterAction),
}
if form.ExpiresIn != nil {
if form.ExpiresIn != nil && *form.ExpiresIn != 0 {
filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
}
for _, context := range form.Context {

View file

@ -21,8 +21,6 @@ import (
"context"
"errors"
"fmt"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
@ -30,6 +28,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
"time"
)
// Update an existing filter for the given account, using the provided parameters.
@ -68,10 +67,16 @@ func (p *Processor) Update(
filterColumns = append(filterColumns, "action")
filter.Action = typeutils.APIFilterActionToFilterAction(*form.FilterAction)
}
// TODO: (Vyr) is it possible to unset a filter expiration with this API?
if form.ExpiresIn != nil {
expiresIn := *form.ExpiresIn
filterColumns = append(filterColumns, "expires_at")
filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(*form.ExpiresIn))
if expiresIn == 0 {
// Unset the expiration date.
filter.ExpiresAt = time.Time{}
} else {
// Update the expiration date.
filter.ExpiresAt = time.Now().Add(time.Second * time.Duration(expiresIn))
}
}
if form.Context != nil {
filterColumns = append(filterColumns,

View file

@ -29,6 +29,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/validate"
)
@ -133,7 +134,7 @@ func (p *Processor) InstanceGetRules(ctx context.Context) ([]apimodel.InstanceRu
return nil, gtserror.NewErrorInternalError(fmt.Errorf("db error fetching instance: %s", err))
}
return p.converter.InstanceRulesToAPIRules(i.Rules), nil
return typeutils.InstanceRulesToAPIRules(i.Rules), nil
}
func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSettingsUpdateRequest) (*apimodel.InstanceV1, gtserror.WithCode) {
@ -227,6 +228,17 @@ func (p *Processor) InstancePatch(ctx context.Context, form *apimodel.InstanceSe
columns = append(columns, []string{"description", "description_text"}...)
}
// validate & update site custom css if it's set on the form
if form.CustomCSS != nil {
customCSS := *form.CustomCSS
if err := validate.InstanceCustomCSS(customCSS); err != nil {
return nil, gtserror.NewErrorBadRequest(err, err.Error())
}
instance.CustomCSS = text.SanitizeToPlaintext(customCSS)
columns = append(columns, []string{"custom_css"}...)
}
// Validate & update site
// terms if set on the form.
if form.Terms != nil {

View file

@ -25,6 +25,7 @@ import (
"codeberg.org/gruf/go-iotools"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -36,18 +37,30 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
// Get maximum supported local media size.
maxsz := config.GetMediaLocalMaxSize()
maxszInt64 := int64(maxsz) // #nosec G115 -- Already validated.
// Ensure media within size bounds.
if form.File.Size > int64(maxsz) {
if form.File.Size > maxszInt64 {
text := fmt.Sprintf("media exceeds configured max size: %s", maxsz)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Parse focus details from API form input.
focusX, focusY, err := parseFocus(form.Focus)
if err != nil {
text := fmt.Sprintf("could not parse focus value %s: %s", form.Focus, err)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
focusX, focusY, errWithCode := apiutil.ParseFocus(form.Focus)
if errWithCode != nil {
return nil, errWithCode
}
// If description provided,
// process and validate it.
//
// This may not yet be set as it
// is often set on status post.
if form.Description != "" {
form.Description, errWithCode = processDescription(form.Description)
if errWithCode != nil {
return nil, errWithCode
}
}
// Open multipart file reader.
@ -57,8 +70,8 @@ func (p *Processor) Create(ctx context.Context, account *gtsmodel.Account, form
return nil, gtserror.NewErrorInternalError(err)
}
// Wrap the multipart file reader to ensure is limited to max.
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, int64(maxsz))
// Wrap multipart file reader to ensure is limited to max size.
rc, _, _ := iotools.UpdateReadCloserLimit(mpfile, maxszInt64)
// Create local media and write to instance storage.
attachment, errWithCode := p.c.StoreLocalMedia(ctx,

View file

@ -177,9 +177,7 @@ func (p *Processor) getAttachmentContent(
}
// Start preparing API content model.
apiContent := &apimodel.Content{
ContentUpdated: attach.UpdatedAt,
}
apiContent := &apimodel.Content{}
// Retrieve appropriate
// size file from storage.

View file

@ -19,6 +19,7 @@ package media_test
import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/admin"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -75,6 +76,7 @@ func (suite *MediaStandardTestSuite) SetupTest() {
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
suite.tc = typeutils.NewConverter(&suite.state)
suite.storage = testrig.NewInMemoryStorage()
suite.state.Storage = suite.storage

View file

@ -20,7 +20,6 @@ package media_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/suite"
)
@ -42,8 +41,6 @@ func (suite *UnattachTestSuite) TestUnattachMedia() {
dbAttachment, errWithCode := suite.db.GetAttachmentByID(ctx, a.ID)
suite.NoError(errWithCode)
suite.WithinDuration(dbAttachment.UpdatedAt, time.Now(), 1*time.Minute)
suite.Empty(dbAttachment.StatusID)
}

View file

@ -23,6 +23,8 @@ import (
"fmt"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -47,17 +49,27 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, media
var updatingColumns []string
if form.Description != nil {
attachment.Description = text.SanitizeToPlaintext(*form.Description)
// Sanitize and validate incoming description.
description, errWithCode := processDescription(
*form.Description,
)
if errWithCode != nil {
return nil, errWithCode
}
attachment.Description = description
updatingColumns = append(updatingColumns, "description")
}
if form.Focus != nil {
focusx, focusy, err := parseFocus(*form.Focus)
if err != nil {
return nil, gtserror.NewErrorBadRequest(err)
// Parse focus details from API form input.
focusX, focusY, errWithCode := apiutil.ParseFocus(*form.Focus)
if errWithCode != nil {
return nil, errWithCode
}
attachment.FileMeta.Focus.X = focusx
attachment.FileMeta.Focus.Y = focusy
attachment.FileMeta.Focus.X = focusX
attachment.FileMeta.Focus.Y = focusY
updatingColumns = append(updatingColumns, "focus_x", "focus_y")
}
@ -72,3 +84,21 @@ func (p *Processor) Update(ctx context.Context, account *gtsmodel.Account, media
return &a, nil
}
// processDescription will sanitize and valid description against server configuration.
func processDescription(description string) (string, gtserror.WithCode) {
description = text.SanitizeToPlaintext(description)
chars := len([]rune(description))
if min := config.GetMediaDescriptionMinChars(); chars < min {
text := fmt.Sprintf("media description less than min chars (%d)", min)
return "", gtserror.NewErrorBadRequest(errors.New(text), text)
}
if max := config.GetMediaDescriptionMaxChars(); chars > max {
text := fmt.Sprintf("media description exceeds max chars (%d)", max)
return "", gtserror.NewErrorBadRequest(errors.New(text), text)
}
return description, nil
}

View file

@ -1,62 +0,0 @@
// 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 media
import (
"fmt"
"strconv"
"strings"
)
func parseFocus(focus string) (focusx, focusy float32, err error) {
if focus == "" {
return
}
spl := strings.Split(focus, ",")
if len(spl) != 2 {
err = fmt.Errorf("improperly formatted focus %s", focus)
return
}
xStr := spl[0]
yStr := spl[1]
if xStr == "" || yStr == "" {
err = fmt.Errorf("improperly formatted focus %s", focus)
return
}
fx, err := strconv.ParseFloat(xStr, 32)
if err != nil {
err = fmt.Errorf("improperly formatted focus %s: %s", focus, err)
return
}
if fx > 1 || fx < -1 {
err = fmt.Errorf("improperly formatted focus %s", focus)
return
}
focusx = float32(fx)
fy, err := strconv.ParseFloat(yStr, 32)
if err != nil {
err = fmt.Errorf("improperly formatted focus %s: %s", focus, err)
return
}
if fy > 1 || fy < -1 {
err = fmt.Errorf("improperly formatted focus %s", focus)
return
}
focusy = float32(fy)
return
}

View file

@ -46,7 +46,7 @@ func (p *Processor) PreferencesGet(ctx context.Context, accountID string) (*apim
func mastoPrefVisibility(vis gtsmodel.Visibility) string {
switch vis {
case gtsmodel.VisibilityPublic, gtsmodel.VisibilityDirect:
return string(vis)
return vis.String()
case gtsmodel.VisibilityUnlocked:
return "unlisted"
default:

View file

@ -48,6 +48,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/processing/user"
"github.com/superseriousbusiness/gotosocial/internal/processing/workers"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/subscriptions"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
)
@ -180,6 +181,7 @@ func (p *Processor) Workers() *workers.Processor {
// NewProcessor returns a new Processor.
func NewProcessor(
cleaner *cleaner.Cleaner,
subscriptions *subscriptions.Subscriptions,
converter *typeutils.Converter,
federator *federation.Federator,
oauthServer oauth.Server,
@ -210,7 +212,7 @@ func NewProcessor(
// Instantiate the rest of the sub
// processors + pin them to this struct.
processor.account = account.New(&common, state, converter, mediaManager, federator, visFilter, parseMentionFunc)
processor.admin = admin.New(&common, state, cleaner, federator, converter, mediaManager, federator.TransportController(), emailSender)
processor.admin = admin.New(&common, state, cleaner, subscriptions, federator, converter, mediaManager, federator.TransportController(), emailSender)
processor.conversations = conversations.New(state, converter, visFilter)
processor.fedi = fedi.New(state, &common, converter, federator, visFilter)
processor.filtersv1 = filtersv1.New(state, converter, &processor.stream)

View file

@ -21,6 +21,7 @@ import (
"context"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/admin"
"github.com/superseriousbusiness/gotosocial/internal/cleaner"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/email"
@ -34,6 +35,7 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/storage"
"github.com/superseriousbusiness/gotosocial/internal/stream"
"github.com/superseriousbusiness/gotosocial/internal/subscriptions"
"github.com/superseriousbusiness/gotosocial/internal/transport"
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
"github.com/superseriousbusiness/gotosocial/testrig"
@ -102,6 +104,7 @@ func (suite *ProcessingStandardTestSuite) SetupTest() {
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
suite.testActivities = testrig.NewTestActivities(suite.testAccounts)
suite.storage = testrig.NewInMemoryStorage()
suite.state.Storage = suite.storage
@ -125,6 +128,7 @@ func (suite *ProcessingStandardTestSuite) SetupTest() {
suite.processor = processing.NewProcessor(
cleaner.New(&suite.state),
subscriptions.New(&suite.state, suite.transportController, suite.typeconverter),
suite.typeconverter,
suite.federator,
suite.oauthServer,

View file

@ -0,0 +1,351 @@
// 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 status
import (
"context"
"errors"
"fmt"
"time"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/text"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
"github.com/superseriousbusiness/gotosocial/internal/validate"
)
// validateStatusContent will validate the common
// content fields across status write endpoints against
// current server configuration (e.g. max char counts).
func validateStatusContent(
status string,
spoiler string,
mediaIDs []string,
poll *apimodel.PollRequest,
) gtserror.WithCode {
totalChars := len([]rune(status)) +
len([]rune(spoiler))
if totalChars == 0 && len(mediaIDs) == 0 && poll == nil {
const text = "status contains no text, media or poll"
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
if max := config.GetStatusesMaxChars(); totalChars > max {
text := fmt.Sprintf("text with spoiler exceed max chars (%d)", max)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
if max := config.GetStatusesMediaMaxFiles(); len(mediaIDs) > max {
text := fmt.Sprintf("media files exceed max count (%d)", max)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
if poll != nil {
switch max := config.GetStatusesPollMaxOptions(); {
case len(poll.Options) == 0:
const text = "poll cannot have no options"
return gtserror.NewErrorBadRequest(errors.New(text), text)
case len(poll.Options) > max:
text := fmt.Sprintf("poll options exceed max count (%d)", max)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
max := config.GetStatusesPollOptionMaxChars()
for i, option := range poll.Options {
switch l := len([]rune(option)); {
case l == 0:
const text = "poll option cannot be empty"
return gtserror.NewErrorBadRequest(errors.New(text), text)
case l > max:
text := fmt.Sprintf("poll option %d exceed max chars (%d)", i, max)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
}
}
return nil
}
// statusContent encompasses the set of common processed
// status content fields from status write operations for
// an easily returnable type, without needing to allocate
// an entire gtsmodel.Status{} model.
type statusContent struct {
Content string
ContentWarning string
PollOptions []string
Language string
MentionIDs []string
Mentions []*gtsmodel.Mention
EmojiIDs []string
Emojis []*gtsmodel.Emoji
TagIDs []string
Tags []*gtsmodel.Tag
}
func (p *Processor) processContent(
ctx context.Context,
author *gtsmodel.Account,
statusID string,
contentType string,
content string,
contentWarning string,
language string,
poll *apimodel.PollRequest,
) (
*statusContent,
gtserror.WithCode,
) {
if language == "" {
// Ensure we have a status language.
language = author.Settings.Language
if language == "" {
const text = "account default language unset"
return nil, gtserror.NewErrorInternalError(
errors.New(text),
)
}
}
var err error
// Validate + normalize determined language.
language, err = validate.Language(language)
if err != nil {
text := fmt.Sprintf("invalid language tag: %v", err)
return nil, gtserror.NewErrorBadRequest(
errors.New(text),
text,
)
}
// format is the currently set text formatting
// function, according to the provided content-type.
var format text.FormatFunc
if contentType == "" {
// If content type wasn't specified, use
// the author's preferred content-type.
contentType = author.Settings.StatusContentType
}
switch contentType {
// Format status according to text/plain.
case "", string(apimodel.StatusContentTypePlain):
format = p.formatter.FromPlain
// Format status according to text/markdown.
case string(apimodel.StatusContentTypeMarkdown):
format = p.formatter.FromMarkdown
// Unknown.
default:
const text = "invalid status format"
return nil, gtserror.NewErrorBadRequest(
errors.New(text),
text,
)
}
// Allocate a structure to hold the
// majority of formatted content without
// needing to alloc a whole gtsmodel.Status{}.
var status statusContent
status.Language = language
// 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, p.parseMention, author.ID, statusID, input)
}
// Sanitize input status text and format.
contentRes := formatInput(format, content)
// Gather results of formatted.
status.Content = contentRes.HTML
status.Mentions = contentRes.Mentions
status.Emojis = contentRes.Emojis
status.Tags = contentRes.Tags
// From here-on-out just use emoji-only
// plain-text formatting as the FormatFunc.
format = p.formatter.FromPlainEmojiOnly
// Sanitize content warning and format.
warning := text.SanitizeToPlaintext(contentWarning)
warningRes := formatInput(format, warning)
// Gather results of the formatted.
status.ContentWarning = warningRes.HTML
status.Emojis = append(status.Emojis, warningRes.Emojis...)
if poll != nil {
// Pre-allocate slice of poll options of expected length.
status.PollOptions = make([]string, len(poll.Options))
for i, option := range poll.Options {
// Sanitize each poll option and format.
option = text.SanitizeToPlaintext(option)
optionRes := formatInput(format, option)
// Gather results of the formatted.
status.PollOptions[i] = optionRes.HTML
status.Emojis = append(status.Emojis, optionRes.Emojis...)
}
// Also update options on the form.
poll.Options = status.PollOptions
}
// We may have received multiple copies of the same emoji, deduplicate these first.
status.Emojis = xslices.DeduplicateFunc(status.Emojis, func(e *gtsmodel.Emoji) string {
return e.ID
})
// Gather up the IDs of mentions from parsed content.
status.MentionIDs = xslices.Gather(nil, status.Mentions,
func(m *gtsmodel.Mention) string {
return m.ID
},
)
// Gather up the IDs of tags from parsed content.
status.TagIDs = xslices.Gather(nil, status.Tags,
func(t *gtsmodel.Tag) string {
return t.ID
},
)
// Gather up the IDs of emojis in updated content.
status.EmojiIDs = xslices.Gather(nil, status.Emojis,
func(e *gtsmodel.Emoji) string {
return e.ID
},
)
return &status, nil
}
func (p *Processor) processMedia(
ctx context.Context,
authorID string,
statusID string,
mediaIDs []string,
) (
[]*gtsmodel.MediaAttachment,
gtserror.WithCode,
) {
// No media provided!
if len(mediaIDs) == 0 {
return nil, nil
}
// Get configured min/max supported descr chars.
minChars := config.GetMediaDescriptionMinChars()
maxChars := config.GetMediaDescriptionMaxChars()
// Pre-allocate slice of media attachments of expected length.
attachments := make([]*gtsmodel.MediaAttachment, len(mediaIDs))
for i, id := range mediaIDs {
// Look for media attachment by ID in database.
media, err := p.state.DB.GetAttachmentByID(ctx, id)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
err := gtserror.Newf("error getting media from db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Check media exists and is owned by author
// (this masks finding out media ownership info).
if media == nil || media.AccountID != authorID {
text := fmt.Sprintf("media not found: %s", id)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Check media isn't already attached to another status.
if (media.StatusID != "" && media.StatusID != statusID) ||
(media.ScheduledStatusID != "" && media.ScheduledStatusID != statusID) {
text := fmt.Sprintf("media already attached to status: %s", id)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Check media description chars within range,
// this needs to be done here as lots of clients
// only update media description on status post.
switch chars := len([]rune(media.Description)); {
case chars < minChars:
text := fmt.Sprintf("media description less than min chars (%d)", minChars)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
case chars > maxChars:
text := fmt.Sprintf("media description exceeds max chars (%d)", maxChars)
return nil, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Set media at index.
attachments[i] = media
}
return attachments, nil
}
func (p *Processor) processPoll(
ctx context.Context,
statusID string,
form *apimodel.PollRequest,
now time.Time, // used for expiry time
) (
*gtsmodel.Poll,
gtserror.WithCode,
) {
var expiresAt time.Time
// Set an expiry time if one given.
if in := form.ExpiresIn; in > 0 {
expiresIn := time.Duration(in)
expiresAt = now.Add(expiresIn * time.Second)
}
// Create new poll model.
poll := &gtsmodel.Poll{
ID: id.NewULIDFromTime(now),
Multiple: &form.Multiple,
HideCounts: &form.HideTotals,
Options: form.Options,
StatusID: statusID,
ExpiresAt: expiresAt,
}
// Insert the newly created poll model in the database.
if err := p.state.DB.PutPoll(ctx, poll); err != nil {
err := gtserror.Newf("error inserting poll in db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return poll, nil
}

View file

@ -19,6 +19,7 @@ package status
import (
"context"
"errors"
"slices"
"strings"
@ -402,6 +403,10 @@ func (p *Processor) WebContextGet(
// We should mark the next **VISIBLE**
// reply as the first reply.
markNextVisibleAsFirstReply bool
// Map of statuses that didn't pass visi
// checks and won't be shown via the web.
hiddenStatuses = make(map[string]struct{})
)
for idx, status := range wholeThread {
@ -427,11 +432,16 @@ func (p *Processor) WebContextGet(
}
}
// Ensure status is actually
// visible to just anyone, and
// hide / don't include it if not.
// Ensure status is actually visible to just
// anyone, and hide / don't include it if not.
//
// Include a check to see if the parent status
// is hidden; if so, we shouldn't show the child
// as it leads to weird-looking threading where
// a status seems to reply to nothing.
_, parentHidden := hiddenStatuses[status.InReplyToID]
v, err := p.visFilter.StatusVisible(ctx, nil, status)
if err != nil || !v {
if err != nil || !v || parentHidden {
if !inReplies {
// Main thread entry hidden.
wCtx.ThreadHidden++
@ -439,12 +449,15 @@ func (p *Processor) WebContextGet(
// Reply hidden.
wCtx.ThreadRepliesHidden++
}
hiddenStatuses[status.ID] = struct{}{}
continue
}
// Prepare visible status to add to thread context.
webStatus, err := p.converter.StatusToWebStatus(ctx, status)
if err != nil {
hiddenStatuses[status.ID] = struct{}{}
continue
}
@ -512,9 +525,17 @@ func (p *Processor) WebContextGet(
wCtx.ThreadLength = threadLength
}
// Jot down number of hidden posts so template doesn't have to do it.
// Jot down number of "main" thread entries shown.
wCtx.ThreadShown = wCtx.ThreadLength - wCtx.ThreadHidden
// If there's no posts visible in the
// "main" thread we shouldn't show replies
// via the web as that's just weird.
if wCtx.ThreadShown < 1 {
const text = "no statuses visible in main thread"
return nil, gtserror.NewErrorNotFound(errors.New(text))
}
// Mark the last "main" visible status.
wCtx.Statuses[wCtx.ThreadShown-1].ThreadLastMain = true
@ -523,7 +544,7 @@ func (p *Processor) WebContextGet(
// part of the "main" thread.
wCtx.ThreadReplies = threadLength - wCtx.ThreadLength
// Jot down number of hidden replies so template doesn't have to do it.
// Jot down number of "replies" shown.
wCtx.ThreadRepliesShown = wCtx.ThreadReplies - wCtx.ThreadRepliesHidden
// Return the finished context.

View file

@ -19,28 +19,22 @@ package status
import (
"context"
"errors"
"fmt"
"time"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"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.
// Note this also handles validation of incoming form field data.
func (p *Processor) Create(
ctx context.Context,
requester *gtsmodel.Account,
@ -50,7 +44,17 @@ func (p *Processor) Create(
*apimodel.Status,
gtserror.WithCode,
) {
// Ensure account populated; we'll need settings.
// Validate incoming form status content.
if errWithCode := validateStatusContent(
form.Status,
form.SpoilerText,
form.MediaIDs,
form.Poll,
); errWithCode != nil {
return nil, errWithCode
}
// Ensure account populated; we'll need their settings.
if err := p.state.DB.PopulateAccount(ctx, requester); err != nil {
log.Errorf(ctx, "error(s) populating account, will continue: %s", err)
}
@ -58,6 +62,30 @@ func (p *Processor) Create(
// Generate new ID for status.
statusID := id.NewULID()
// Process incoming status content fields.
content, errWithCode := p.processContent(ctx,
requester,
statusID,
string(form.ContentType),
form.Status,
form.SpoilerText,
form.Language,
form.Poll,
)
if errWithCode != nil {
return nil, errWithCode
}
// Process incoming status attachments.
media, errWithCode := p.processMedia(ctx,
requester.ID,
statusID,
form.MediaIDs,
)
if errWithCode != nil {
return nil, errWithCode
}
// Generate necessary URIs for username, to build status URIs.
accountURIs := uris.GenerateURIsForAccount(requester.Username)
@ -69,7 +97,6 @@ func (p *Processor) Create(
URI: accountURIs.StatusesURI + "/" + statusID,
URL: accountURIs.StatusesURL + "/" + statusID,
CreatedAt: now,
UpdatedAt: now,
Local: util.Ptr(true),
Account: requester,
AccountID: requester.ID,
@ -77,27 +104,34 @@ func (p *Processor) Create(
ActivityStreamsType: ap.ObjectNote,
Sensitive: &form.Sensitive,
CreatedWithApplicationID: application.ID,
Text: form.Status,
}
if form.Poll != nil {
// Update the status AS type to "Question".
status.ActivityStreamsType = ap.ActivityQuestion
// Set validated language.
Language: content.Language,
// Create new poll for status from form.
secs := time.Duration(form.Poll.ExpiresIn)
status.Poll = &gtsmodel.Poll{
ID: id.NewULID(),
Multiple: &form.Poll.Multiple,
HideCounts: &form.Poll.HideTotals,
Options: form.Poll.Options,
StatusID: statusID,
Status: status,
ExpiresAt: now.Add(secs * time.Second),
}
// Set formatted status content.
Content: content.Content,
ContentWarning: content.ContentWarning,
Text: form.Status, // raw
// Set poll ID on the status.
status.PollID = status.Poll.ID
// Set gathered mentions.
MentionIDs: content.MentionIDs,
Mentions: content.Mentions,
// Set gathered emojis.
EmojiIDs: content.EmojiIDs,
Emojis: content.Emojis,
// Set gathered tags.
TagIDs: content.TagIDs,
Tags: content.Tags,
// Set gathered media.
AttachmentIDs: form.MediaIDs,
Attachments: media,
// Assume not pending approval; this may
// change when permissivity is checked.
PendingApproval: util.Ptr(false),
}
// Check + attach in-reply-to status.
@ -113,10 +147,6 @@ func (p *Processor) Create(
return nil, errWithCode
}
if errWithCode := p.processMediaIDs(ctx, form, requester.ID, status); errWithCode != nil {
return nil, errWithCode
}
if err := p.processVisibility(ctx, form, requester.Settings.Privacy, status); err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
@ -127,28 +157,49 @@ func (p *Processor) Create(
return nil, errWithCode
}
if err := processLanguage(form, requester.Settings.Language, status); err != nil {
if status.ContentWarning != "" && len(status.AttachmentIDs) > 0 {
// If a content-warning is set, and
// the status contains media, always
// set the status sensitive flag.
status.Sensitive = util.Ptr(true)
}
if form.Poll != nil {
// Process poll, inserting into database.
poll, errWithCode := p.processPoll(ctx,
statusID,
form.Poll,
now,
)
if errWithCode != nil {
return nil, errWithCode
}
// Set poll and its ID
// on status before insert.
status.PollID = poll.ID
status.Poll = poll
poll.Status = status
// Update the status' ActivityPub type to Question.
status.ActivityStreamsType = ap.ActivityQuestion
}
// Insert this newly prepared status into the database.
if err := p.state.DB.PutStatus(ctx, status); err != nil {
err := gtserror.Newf("error inserting status in db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if err := p.processContent(ctx, p.parseMention, form, status); err != nil {
return nil, gtserror.NewErrorInternalError(err)
}
if status.Poll != nil {
// Try to insert the new status poll in the database.
if err := p.state.DB.PutPoll(ctx, status.Poll); err != nil {
err := gtserror.Newf("error inserting poll in db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
if status.Poll != nil && !status.Poll.ExpiresAt.IsZero() {
// Now that the status is inserted, attempt to
// schedule an expiry handler for the status poll.
if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil {
log.Errorf(ctx, "error scheduling poll expiry: %v", err)
}
}
// 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 client API worker for async side-effects.
// Send it to the client API worker for async side-effects.
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityCreate,
@ -156,14 +207,6 @@ func (p *Processor) Create(
Origin: requester,
})
if status.Poll != nil {
// Now that the status is inserted, and side effects queued,
// attempt to schedule an expiry handler for the status poll.
if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil {
log.Errorf(ctx, "error scheduling poll expiry: %v", err)
}
}
// If the new status replies to a status that
// replies to us, use our reply as an implicit
// accept of any pending interaction.
@ -307,53 +350,6 @@ func (p *Processor) processThreadID(ctx context.Context, status *gtsmodel.Status
return nil
}
func (p *Processor) processMediaIDs(ctx context.Context, form *apimodel.StatusCreateRequest, 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 := 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 {
text := fmt.Sprintf("media %s does not belong to account", mediaID)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
if attachment.StatusID != "" || attachment.ScheduledStatusID != "" {
text := fmt.Sprintf("media %s already attached to status", mediaID)
return gtserror.NewErrorBadRequest(errors.New(text), text)
}
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)
attachmentIDs = append(attachmentIDs, attachment.ID)
}
status.Attachments = attachments
status.AttachmentIDs = attachmentIDs
return nil
}
func (p *Processor) processVisibility(
ctx context.Context,
form *apimodel.StatusCreateRequest,
@ -367,7 +363,7 @@ func (p *Processor) processVisibility(
// Fall back to account default, set
// this back on the form for later use.
case accountDefaultVis != "":
case accountDefaultVis != 0:
status.Visibility = accountDefaultVis
form.Visibility = p.converter.VisToAPIVis(ctx, accountDefaultVis)
@ -449,99 +445,3 @@ func processInteractionPolicy(
// setting it explicitly to save space.
return nil
}
func processLanguage(form *apimodel.StatusCreateRequest, accountDefaultLanguage string, status *gtsmodel.Status) error {
if form.Language != "" {
status.Language = form.Language
} else {
status.Language = accountDefaultLanguage
}
if status.Language == "" {
return errors.New("no language given either in status create form or account default")
}
return nil
}
func (p *Processor) processContent(ctx context.Context, parseMention gtsmodel.ParseMentionFunc, form *apimodel.StatusCreateRequest, status *gtsmodel.Status) error {
if form.ContentType == "" {
// If content type wasn't specified, use the author's preferred content-type.
contentType := apimodel.StatusContentType(status.Account.Settings.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)
}
switch form.ContentType {
// None given / set,
// use default (plain).
case "":
fallthrough
// Format status according to text/plain.
case apimodel.StatusContentTypePlain:
format = p.formatter.FromPlain
// Format status according to text/markdown.
case apimodel.StatusContentTypeMarkdown:
format = p.formatter.FromMarkdown
// Unknown.
default:
return fmt.Errorf("invalid status format: %q", form.ContentType)
}
// Sanitize status text and format.
contentRes := formatInput(format, form.Status)
// 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...)
// 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...)
if status.Poll != nil {
for i := range status.Poll.Options {
// Sanitize each option title name and format.
option := text.SanitizeToPlaintext(status.Poll.Options[i])
optionRes := formatInput(format, option)
// Collect each formatted result.
status.Poll.Options[i] = optionRes.HTML
status.Emojis = append(status.Emojis, optionRes.Emojis...)
}
}
// Gather all the database IDs from each of the gathered status mentions, tags, and emojis.
status.MentionIDs = util.Gather(nil, status.Mentions, func(mention *gtsmodel.Mention) string { return mention.ID })
status.TagIDs = util.Gather(nil, status.Tags, func(tag *gtsmodel.Tag) string { return tag.ID })
status.EmojiIDs = util.Gather(nil, status.Emojis, func(emoji *gtsmodel.Emoji) string { return emoji.ID })
if status.ContentWarning != "" && len(status.AttachmentIDs) > 0 {
// If a content-warning is set, and
// the status contains media, always
// set the status sensitive flag.
status.Sensitive = util.Ptr(true)
}
return nil
}

View file

@ -170,7 +170,7 @@ func (suite *StatusCreateTestSuite) TestProcessMediaDescriptionTooShort() {
}
apiStatus, err := suite.status.Create(ctx, creatingAccount, creatingApplication, statusCreateForm)
suite.EqualError(err, "media 01F8MH8RMYQ6MSNY3JM2XT1CQ5 description too short, at least 100 required")
suite.EqualError(err, "media description less than min chars (100)")
suite.Nil(apiStatus)
}

View file

@ -0,0 +1,555 @@
// 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 status
import (
"context"
"errors"
"fmt"
"slices"
"time"
"github.com/superseriousbusiness/gotosocial/internal/ap"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
)
// Edit ...
func (p *Processor) Edit(
ctx context.Context,
requester *gtsmodel.Account,
statusID string,
form *apimodel.StatusEditRequest,
) (
*apimodel.Status,
gtserror.WithCode,
) {
// Fetch status and ensure it's owned by requesting account.
status, errWithCode := p.c.GetOwnStatus(ctx, requester, statusID)
if errWithCode != nil {
return nil, errWithCode
}
// Ensure this isn't a boost.
if status.BoostOfID != "" {
return nil, gtserror.NewErrorNotFound(
errors.New("status is a boost wrapper"),
"target status not found",
)
}
// Ensure account populated; we'll need their settings.
if err := p.state.DB.PopulateAccount(ctx, requester); err != nil {
log.Errorf(ctx, "error(s) populating account, will continue: %s", err)
}
// We need the status populated including all historical edits.
if err := p.state.DB.PopulateStatusEdits(ctx, status); err != nil {
err := gtserror.Newf("error getting status edits from db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Time of edit.
now := time.Now()
// Validate incoming form edit content.
if errWithCode := validateStatusContent(
form.Status,
form.SpoilerText,
form.MediaIDs,
form.Poll,
); errWithCode != nil {
return nil, errWithCode
}
// Process incoming status edit content fields.
content, errWithCode := p.processContent(ctx,
requester,
statusID,
string(form.ContentType),
form.Status,
form.SpoilerText,
form.Language,
form.Poll,
)
if errWithCode != nil {
return nil, errWithCode
}
// Process new status attachments to use.
media, errWithCode := p.processMedia(ctx,
requester.ID,
statusID,
form.MediaIDs,
)
if errWithCode != nil {
return nil, errWithCode
}
// Process incoming edits of any attached media.
mediaEdited, errWithCode := p.processMediaEdits(ctx,
media,
form.MediaAttributes,
)
if errWithCode != nil {
return nil, errWithCode
}
// Process incoming edits of any attached status poll.
poll, pollEdited, errWithCode := p.processPollEdit(ctx,
statusID,
status.Poll,
form.Poll,
now,
)
if errWithCode != nil {
return nil, errWithCode
}
// Check if new status poll was set.
pollChanged := (poll != status.Poll)
// Determine whether there were any changes possibly
// causing a change to embedded mentions, tags, emojis.
contentChanged := (status.Content != content.Content)
warningChanged := (status.ContentWarning != content.ContentWarning)
languageChanged := (status.Language != content.Language)
anyContentChanged := contentChanged || warningChanged ||
pollEdited // encapsulates pollChanged too
// Check if status media attachments have changed.
mediaChanged := !slices.Equal(status.AttachmentIDs,
form.MediaIDs,
)
// Track status columns we
// need to update in database.
cols := make([]string, 2, 13)
cols[0] = "edited_at"
cols[1] = "edits"
if contentChanged {
// Update status text.
//
// Note we don't update these
// status fields right away so
// we can save current version.
cols = append(cols, "content")
cols = append(cols, "text")
}
if warningChanged {
// Update status content warning.
//
// Note we don't update these
// status fields right away so
// we can save current version.
cols = append(cols, "content_warning")
}
if languageChanged {
// Update status language pref.
//
// Note we don't update these
// status fields right away so
// we can save current version.
cols = append(cols, "language")
}
if *status.Sensitive != form.Sensitive {
// Update status sensitivity pref.
//
// Note we don't update these
// status fields right away so
// we can save current version.
cols = append(cols, "sensitive")
}
if mediaChanged {
// Updated status media attachments.
//
// Note we don't update these
// status fields right away so
// we can save current version.
cols = append(cols, "attachments")
}
if pollChanged {
// Updated attached status poll.
//
// Note we don't update these
// status fields right away so
// we can save current version.
cols = append(cols, "poll_id")
if status.Poll == nil || poll == nil {
// Went from with-poll to without-poll
// or vice-versa. This changes AP type.
cols = append(cols, "activity_streams_type")
}
}
if anyContentChanged {
if !slices.Equal(status.MentionIDs, content.MentionIDs) {
// Update attached status mentions.
cols = append(cols, "mentions")
status.MentionIDs = content.MentionIDs
status.Mentions = content.Mentions
}
if !slices.Equal(status.TagIDs, content.TagIDs) {
// Updated attached status tags.
cols = append(cols, "tags")
status.TagIDs = content.TagIDs
status.Tags = content.Tags
}
if !slices.Equal(status.EmojiIDs, content.EmojiIDs) {
// We specifically store both *new* AND *old* edit
// revision emojis in the statuses.emojis column.
emojiByID := func(e *gtsmodel.Emoji) string { return e.ID }
status.Emojis = append(status.Emojis, content.Emojis...)
status.Emojis = xslices.DeduplicateFunc(status.Emojis, emojiByID)
status.EmojiIDs = xslices.Gather(status.EmojiIDs[:0], status.Emojis, emojiByID)
// Update attached status emojis.
cols = append(cols, "emojis")
}
}
// If no status columns were updated, no media and
// no poll were edited, there's nothing to do!
if len(cols) == 2 && !mediaEdited && !pollEdited {
const text = "status was not changed"
return nil, gtserror.NewErrorUnprocessableEntity(
errors.New(text),
text,
)
}
// Create an edit to store a
// historical snapshot of status.
var edit gtsmodel.StatusEdit
edit.ID = id.NewULIDFromTime(now)
edit.Content = status.Content
edit.ContentWarning = status.ContentWarning
edit.Text = status.Text
edit.Language = status.Language
edit.Sensitive = status.Sensitive
edit.StatusID = status.ID
edit.CreatedAt = status.UpdatedAt()
// Copy existing media and descriptions.
edit.AttachmentIDs = status.AttachmentIDs
if l := len(status.Attachments); l > 0 {
edit.AttachmentDescriptions = make([]string, l)
for i, attach := range status.Attachments {
edit.AttachmentDescriptions[i] = attach.Description
}
}
if status.Poll != nil {
// Poll only set if existed previously.
edit.PollOptions = status.Poll.Options
if pollChanged || !*status.Poll.HideCounts ||
!status.Poll.ClosedAt.IsZero() {
// If the counts are allowed to be
// shown, or poll has changed, then
// include poll vote counts in edit.
edit.PollVotes = status.Poll.Votes
}
}
// Insert this new edit of existing status into database.
if err := p.state.DB.PutStatusEdit(ctx, &edit); err != nil {
err := gtserror.Newf("error putting edit in database: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
// Add edit to list of edits on the status.
status.EditIDs = append(status.EditIDs, edit.ID)
status.Edits = append(status.Edits, &edit)
// Now historical status data is stored,
// update the other necessary status fields.
status.Content = content.Content
status.ContentWarning = content.ContentWarning
status.Text = form.Status
status.Language = content.Language
status.Sensitive = &form.Sensitive
status.AttachmentIDs = form.MediaIDs
status.Attachments = media
status.EditedAt = now
if poll != nil {
// Set relevent fields for latest with poll.
status.ActivityStreamsType = ap.ActivityQuestion
status.PollID = poll.ID
status.Poll = poll
} else {
// Set relevant fields for latest without poll.
status.ActivityStreamsType = ap.ObjectNote
status.PollID = ""
status.Poll = nil
}
// Finally update the existing status model in the database.
if err := p.state.DB.UpdateStatus(ctx, status, cols...); err != nil {
err := gtserror.Newf("error updating status in db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
if pollChanged && status.Poll != nil && !status.Poll.ExpiresAt.IsZero() {
// Now the status is updated, attempt to schedule
// an expiry handler for the changed status poll.
if err := p.polls.ScheduleExpiry(ctx, status.Poll); err != nil {
log.Errorf(ctx, "error scheduling poll expiry: %v", err)
}
}
// Send it to the client API worker for async side-effects.
p.state.Workers.Client.Queue.Push(&messages.FromClientAPI{
APObjectType: ap.ObjectNote,
APActivityType: ap.ActivityUpdate,
GTSModel: status,
Origin: requester,
})
// Return an API model of the updated status.
return p.c.GetAPIStatus(ctx, requester, status)
}
// HistoryGet gets edit history for the target status, taking account of privacy settings and blocks etc.
func (p *Processor) HistoryGet(ctx context.Context, requester *gtsmodel.Account, targetStatusID string) ([]*apimodel.StatusEdit, gtserror.WithCode) {
target, errWithCode := p.c.GetVisibleTargetStatus(ctx,
requester,
targetStatusID,
nil, // default freshness
)
if errWithCode != nil {
return nil, errWithCode
}
if err := p.state.DB.PopulateStatusEdits(ctx, target); err != nil {
err := gtserror.Newf("error getting status edits from db: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
edits, err := p.converter.StatusToAPIEdits(ctx, target)
if err != nil {
err := gtserror.Newf("error converting status edits: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return edits, nil
}
func (p *Processor) processMediaEdits(
ctx context.Context,
attachs []*gtsmodel.MediaAttachment,
attrs []apimodel.AttachmentAttributesRequest,
) (
bool,
gtserror.WithCode,
) {
var edited bool
for _, attr := range attrs {
// Search the media attachments slice for index of media with attr.ID.
i := slices.IndexFunc(attachs, func(m *gtsmodel.MediaAttachment) bool {
return m.ID == attr.ID
})
if i == -1 {
text := fmt.Sprintf("media not found: %s", attr.ID)
return false, gtserror.NewErrorBadRequest(errors.New(text), text)
}
// Get attach at index.
attach := attachs[i]
// Track which columns need
// updating in database query.
cols := make([]string, 0, 2)
// Check for description change.
if attr.Description != attach.Description {
attach.Description = attr.Description
cols = append(cols, "description")
}
if attr.Focus != "" {
// Parse provided media focus parameters from string.
fx, fy, errWithCode := apiutil.ParseFocus(attr.Focus)
if errWithCode != nil {
return false, errWithCode
}
// Check for change in focus coords.
if attach.FileMeta.Focus.X != fx ||
attach.FileMeta.Focus.Y != fy {
attach.FileMeta.Focus.X = fx
attach.FileMeta.Focus.Y = fy
cols = append(cols, "focus_x", "focus_y")
}
}
if len(cols) > 0 {
// Media attachment was changed, update this in database.
err := p.state.DB.UpdateAttachment(ctx, attach, cols...)
if err != nil {
err := gtserror.Newf("error updating attachment in db: %w", err)
return false, gtserror.NewErrorInternalError(err)
}
// Set edited.
edited = true
}
}
return edited, nil
}
func (p *Processor) processPollEdit(
ctx context.Context,
statusID string,
original *gtsmodel.Poll,
form *apimodel.PollRequest,
now time.Time, // used for expiry time
) (
*gtsmodel.Poll,
bool,
gtserror.WithCode,
) {
if form == nil {
if original != nil {
// No poll was given but there's an existing poll,
// this indicates the original needs to be deleted.
if err := p.deletePoll(ctx, original); err != nil {
return nil, true, gtserror.NewErrorInternalError(err)
}
// Existing was deleted.
return nil, true, nil
}
// No change in poll.
return nil, false, nil
}
switch {
// No existing poll.
case original == nil:
// Any change that effects voting, i.e. options, allow multiple
// or re-opening a closed poll requires deleting the existing poll.
case !slices.Equal(form.Options, original.Options) ||
(form.Multiple != *original.Multiple) ||
(!original.ClosedAt.IsZero() && form.ExpiresIn != 0):
if err := p.deletePoll(ctx, original); err != nil {
return nil, true, gtserror.NewErrorInternalError(err)
}
// Any other changes only require a model
// update, and at-most a new expiry handler.
default:
var cols []string
// Check if the hide counts field changed.
if form.HideTotals != *original.HideCounts {
cols = append(cols, "hide_counts")
original.HideCounts = &form.HideTotals
}
var expiresAt time.Time
// Determine expiry time if given.
if in := form.ExpiresIn; in > 0 {
expiresIn := time.Duration(in)
expiresAt = now.Add(expiresIn * time.Second)
}
// Check for expiry time.
if !expiresAt.IsZero() {
if !original.ExpiresAt.IsZero() {
// Existing had expiry, cancel scheduled handler.
_ = p.state.Workers.Scheduler.Cancel(original.ID)
}
// Since expiry is given as a duration
// we always treat > 0 as a change as
// we can't know otherwise unfortunately.
cols = append(cols, "expires_at")
original.ExpiresAt = expiresAt
}
if len(cols) == 0 {
// Were no changes to poll.
return original, false, nil
}
// Update the original poll model in the database with these columns.
if err := p.state.DB.UpdatePoll(ctx, original, cols...); err != nil {
err := gtserror.Newf("error updating poll.expires_at in db: %w", err)
return nil, true, gtserror.NewErrorInternalError(err)
}
if !expiresAt.IsZero() {
// Updated poll has an expiry, schedule a new expiry handler.
if err := p.polls.ScheduleExpiry(ctx, original); err != nil {
log.Errorf(ctx, "error scheduling poll expiry: %v", err)
}
}
// Existing poll was updated.
return original, true, nil
}
// If we reached here then an entirely
// new status poll needs to be created.
poll, errWithCode := p.processPoll(ctx,
statusID,
form,
now,
)
return poll, true, errWithCode
}
func (p *Processor) deletePoll(ctx context.Context, poll *gtsmodel.Poll) error {
if !poll.ExpiresAt.IsZero() && !poll.ClosedAt.IsZero() {
// Poll has an expiry and has not yet closed,
// cancel any expiry handler before deletion.
_ = p.state.Workers.Scheduler.Cancel(poll.ID)
}
// Delete the given poll from the database.
err := p.state.DB.DeletePollByID(ctx, poll.ID)
if err != nil && !errors.Is(err, db.ErrNoEntries) {
return gtserror.Newf("error deleting poll from db: %w", err)
}
return nil
}

View file

@ -0,0 +1,544 @@
// 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 status_test
import (
"context"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/suite"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
"github.com/superseriousbusiness/gotosocial/internal/util/xslices"
)
type StatusEditTestSuite struct {
StatusStandardTestSuite
}
func (suite *StatusEditTestSuite) TestSimpleEdit() {
// Create cancellable context to use for test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// Get a local account to use as test requester.
requester := suite.testAccounts["local_account_1"]
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
// Get requester's existing status to perform an edit on.
status := suite.testStatuses["local_account_1_status_9"]
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
// Prepare a simple status edit.
form := &apimodel.StatusEditRequest{
Status: "<p>this is some edited status text!</p>",
SpoilerText: "shhhhh",
Sensitive: true,
Language: "fr", // hoh hoh hoh
MediaIDs: nil,
MediaAttributes: nil,
Poll: nil,
}
// Pass the prepared form to the status processor to perform the edit.
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
suite.NotNil(apiStatus)
suite.NoError(errWithCode)
// Check response against input form data.
suite.Equal(form.Status, apiStatus.Text)
suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
suite.Equal(form.Sensitive, apiStatus.Sensitive)
suite.Equal(form.Language, *apiStatus.Language)
suite.NotEqual(util.FormatISO8601(status.EditedAt), *apiStatus.EditedAt)
// Fetched the latest version of edited status from the database.
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
suite.NoError(err)
// Check latest status against input form data.
suite.Equal(form.Status, latestStatus.Text)
suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
suite.Equal(form.Sensitive, *latestStatus.Sensitive)
suite.Equal(form.Language, latestStatus.Language)
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
suite.NotEqual(status.UpdatedAt(), latestStatus.UpdatedAt())
// Populate all historical edits for this status.
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
suite.NoError(err)
// Check previous status edit matches original status content.
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
suite.Equal(status.Content, previousEdit.Content)
suite.Equal(status.Text, previousEdit.Text)
suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
suite.Equal(status.Language, previousEdit.Language)
suite.Equal(status.UpdatedAt(), previousEdit.CreatedAt)
}
func (suite *StatusEditTestSuite) TestEditAddPoll() {
// Create cancellable context to use for test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// Get a local account to use as test requester.
requester := suite.testAccounts["local_account_1"]
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
// Get requester's existing status to perform an edit on.
status := suite.testStatuses["local_account_1_status_9"]
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
// Prepare edit adding a status poll.
form := &apimodel.StatusEditRequest{
Status: "<p>this is some edited status text!</p>",
SpoilerText: "",
Sensitive: true,
Language: "fr", // hoh hoh hoh
MediaIDs: nil,
MediaAttributes: nil,
Poll: &apimodel.PollRequest{
Options: []string{"yes", "no", "spiderman"},
ExpiresIn: int(time.Minute),
Multiple: true,
HideTotals: false,
},
}
// Pass the prepared form to the status processor to perform the edit.
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
suite.NotNil(apiStatus)
suite.NoError(errWithCode)
// Check response against input form data.
suite.Equal(form.Status, apiStatus.Text)
suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
suite.Equal(form.Sensitive, apiStatus.Sensitive)
suite.Equal(form.Language, *apiStatus.Language)
suite.NotEqual(util.FormatISO8601(status.EditedAt), *apiStatus.EditedAt)
suite.NotNil(apiStatus.Poll)
suite.Equal(form.Poll.Options, xslices.Gather(nil, apiStatus.Poll.Options, func(opt apimodel.PollOption) string {
return opt.Title
}))
// Fetched the latest version of edited status from the database.
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
suite.NoError(err)
// Check latest status against input form data.
suite.Equal(form.Status, latestStatus.Text)
suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
suite.Equal(form.Sensitive, *latestStatus.Sensitive)
suite.Equal(form.Language, latestStatus.Language)
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
suite.NotEqual(status.UpdatedAt(), latestStatus.UpdatedAt())
suite.NotNil(latestStatus.Poll)
suite.Equal(form.Poll.Options, latestStatus.Poll.Options)
// Ensure that a poll expiry handler was scheduled on status edit.
expiryWorker := suite.state.Workers.Scheduler.Cancel(latestStatus.PollID)
suite.Equal(form.Poll.ExpiresIn > 0, expiryWorker)
// Populate all historical edits for this status.
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
suite.NoError(err)
// Check previous status edit matches original status content.
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
suite.Equal(status.Content, previousEdit.Content)
suite.Equal(status.Text, previousEdit.Text)
suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
suite.Equal(status.Language, previousEdit.Language)
suite.Equal(status.UpdatedAt(), previousEdit.CreatedAt)
suite.Equal(status.Poll != nil, len(previousEdit.PollOptions) > 0)
}
func (suite *StatusEditTestSuite) TestEditAddPollNoExpiry() {
// Create cancellable context to use for test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// Get a local account to use as test requester.
requester := suite.testAccounts["local_account_1"]
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
// Get requester's existing status to perform an edit on.
status := suite.testStatuses["local_account_1_status_9"]
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
// Prepare edit adding an endless poll.
form := &apimodel.StatusEditRequest{
Status: "<p>this is some edited status text!</p>",
SpoilerText: "",
Sensitive: true,
Language: "fr", // hoh hoh hoh
MediaIDs: nil,
MediaAttributes: nil,
Poll: &apimodel.PollRequest{
Options: []string{"yes", "no", "spiderman"},
ExpiresIn: 0,
Multiple: true,
HideTotals: false,
},
}
// Pass the prepared form to the status processor to perform the edit.
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
suite.NotNil(apiStatus)
suite.NoError(errWithCode)
// Check response against input form data.
suite.Equal(form.Status, apiStatus.Text)
suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
suite.Equal(form.Sensitive, apiStatus.Sensitive)
suite.Equal(form.Language, *apiStatus.Language)
suite.NotEqual(util.FormatISO8601(status.EditedAt), *apiStatus.EditedAt)
suite.NotNil(apiStatus.Poll)
suite.Equal(form.Poll.Options, xslices.Gather(nil, apiStatus.Poll.Options, func(opt apimodel.PollOption) string {
return opt.Title
}))
// Fetched the latest version of edited status from the database.
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
suite.NoError(err)
// Check latest status against input form data.
suite.Equal(form.Status, latestStatus.Text)
suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
suite.Equal(form.Sensitive, *latestStatus.Sensitive)
suite.Equal(form.Language, latestStatus.Language)
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
suite.NotEqual(status.UpdatedAt(), latestStatus.UpdatedAt())
suite.NotNil(latestStatus.Poll)
suite.Equal(form.Poll.Options, latestStatus.Poll.Options)
// Ensure that a poll expiry handler was *not* scheduled on status edit.
expiryWorker := suite.state.Workers.Scheduler.Cancel(latestStatus.PollID)
suite.Equal(form.Poll.ExpiresIn > 0, expiryWorker)
// Populate all historical edits for this status.
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
suite.NoError(err)
// Check previous status edit matches original status content.
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
suite.Equal(status.Content, previousEdit.Content)
suite.Equal(status.Text, previousEdit.Text)
suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
suite.Equal(status.Language, previousEdit.Language)
suite.Equal(status.UpdatedAt(), previousEdit.CreatedAt)
suite.Equal(status.Poll != nil, len(previousEdit.PollOptions) > 0)
}
func (suite *StatusEditTestSuite) TestEditMediaDescription() {
// Create cancellable context to use for test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// Get a local account to use as test requester.
requester := suite.testAccounts["local_account_1"]
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
// Get requester's existing status to perform an edit on.
status := suite.testStatuses["local_account_1_status_4"]
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
// Prepare edit changing media description.
form := &apimodel.StatusEditRequest{
Status: "<p>this is some edited status text!</p>",
SpoilerText: "this status is now missing media",
Sensitive: true,
Language: "en",
MediaIDs: status.AttachmentIDs,
MediaAttributes: []apimodel.AttachmentAttributesRequest{
{ID: status.AttachmentIDs[0], Description: "hello world!"},
{ID: status.AttachmentIDs[1], Description: "media attachment numero two"},
},
}
// Pass the prepared form to the status processor to perform the edit.
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
suite.NoError(errWithCode)
// Check response against input form data.
suite.Equal(form.Status, apiStatus.Text)
suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
suite.Equal(form.Sensitive, apiStatus.Sensitive)
suite.Equal(form.Language, *apiStatus.Language)
suite.NotEqual(util.FormatISO8601(status.EditedAt), *apiStatus.EditedAt)
suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string {
return media.ID
}))
suite.Equal(
xslices.Gather(nil, form.MediaAttributes, func(attr apimodel.AttachmentAttributesRequest) string {
return attr.Description
}),
xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string {
return *media.Description
}),
)
// Fetched the latest version of edited status from the database.
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
suite.NoError(err)
// Check latest status against input form data.
suite.Equal(form.Status, latestStatus.Text)
suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
suite.Equal(form.Sensitive, *latestStatus.Sensitive)
suite.Equal(form.Language, latestStatus.Language)
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
suite.NotEqual(status.UpdatedAt(), latestStatus.UpdatedAt())
suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs)
suite.Equal(
xslices.Gather(nil, form.MediaAttributes, func(attr apimodel.AttachmentAttributesRequest) string {
return attr.Description
}),
xslices.Gather(nil, latestStatus.Attachments, func(media *gtsmodel.MediaAttachment) string {
return media.Description
}),
)
// Populate all historical edits for this status.
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
suite.NoError(err)
// Further populate edits to get attachments.
for _, edit := range latestStatus.Edits {
err = suite.state.DB.PopulateStatusEdit(ctx, edit)
suite.NoError(err)
}
// Check previous status edit matches original status content.
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
suite.Equal(status.Content, previousEdit.Content)
suite.Equal(status.Text, previousEdit.Text)
suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
suite.Equal(status.Language, previousEdit.Language)
suite.Equal(status.UpdatedAt(), previousEdit.CreatedAt)
suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs)
suite.Equal(
xslices.Gather(nil, status.Attachments, func(media *gtsmodel.MediaAttachment) string {
return media.Description
}),
previousEdit.AttachmentDescriptions,
)
}
func (suite *StatusEditTestSuite) TestEditAddMedia() {
// Create cancellable context to use for test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// Get a local account to use as test requester.
requester := suite.testAccounts["local_account_1"]
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
// Get some of requester's existing media, and unattach from existing status.
media1 := suite.testAttachments["local_account_1_status_4_attachment_1"]
media2 := suite.testAttachments["local_account_1_status_4_attachment_2"]
media1.StatusID, media2.StatusID = "", ""
suite.NoError(suite.state.DB.UpdateAttachment(ctx, media1, "status_id"))
suite.NoError(suite.state.DB.UpdateAttachment(ctx, media2, "status_id"))
media1, _ = suite.state.DB.GetAttachmentByID(ctx, media1.ID)
media2, _ = suite.state.DB.GetAttachmentByID(ctx, media2.ID)
// Get requester's existing status to perform an edit on.
status := suite.testStatuses["local_account_1_status_9"]
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
// Prepare edit addding status media.
form := &apimodel.StatusEditRequest{
Status: "<p>this is some edited status text!</p>",
SpoilerText: "this status now has media",
Sensitive: true,
Language: "en",
MediaIDs: []string{media1.ID, media2.ID},
MediaAttributes: nil,
}
// Pass the prepared form to the status processor to perform the edit.
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
suite.NotNil(apiStatus)
suite.NoError(errWithCode)
// Check response against input form data.
suite.Equal(form.Status, apiStatus.Text)
suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
suite.Equal(form.Sensitive, apiStatus.Sensitive)
suite.Equal(form.Language, *apiStatus.Language)
suite.NotEqual(util.FormatISO8601(status.EditedAt), *apiStatus.EditedAt)
suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string {
return media.ID
}))
// Fetched the latest version of edited status from the database.
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
suite.NoError(err)
// Check latest status against input form data.
suite.Equal(form.Status, latestStatus.Text)
suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
suite.Equal(form.Sensitive, *latestStatus.Sensitive)
suite.Equal(form.Language, latestStatus.Language)
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
suite.NotEqual(status.UpdatedAt(), latestStatus.UpdatedAt())
suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs)
// Populate all historical edits for this status.
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
suite.NoError(err)
// Check previous status edit matches original status content.
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
suite.Equal(status.Content, previousEdit.Content)
suite.Equal(status.Text, previousEdit.Text)
suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
suite.Equal(status.Language, previousEdit.Language)
suite.Equal(status.UpdatedAt(), previousEdit.CreatedAt)
suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs)
}
func (suite *StatusEditTestSuite) TestEditRemoveMedia() {
// Create cancellable context to use for test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// Get a local account to use as test requester.
requester := suite.testAccounts["local_account_1"]
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
// Get requester's existing status to perform an edit on.
status := suite.testStatuses["local_account_1_status_4"]
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
// Prepare edit removing status media.
form := &apimodel.StatusEditRequest{
Status: "<p>this is some edited status text!</p>",
SpoilerText: "this status is now missing media",
Sensitive: true,
Language: "en",
MediaIDs: nil,
MediaAttributes: nil,
}
// Pass the prepared form to the status processor to perform the edit.
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
suite.NotNil(apiStatus)
suite.NoError(errWithCode)
// Check response against input form data.
suite.Equal(form.Status, apiStatus.Text)
suite.Equal(form.SpoilerText, apiStatus.SpoilerText)
suite.Equal(form.Sensitive, apiStatus.Sensitive)
suite.Equal(form.Language, *apiStatus.Language)
suite.NotEqual(util.FormatISO8601(status.EditedAt), *apiStatus.EditedAt)
suite.Equal(form.MediaIDs, xslices.Gather(nil, apiStatus.MediaAttachments, func(media *apimodel.Attachment) string {
return media.ID
}))
// Fetched the latest version of edited status from the database.
latestStatus, err := suite.state.DB.GetStatusByID(ctx, status.ID)
suite.NoError(err)
// Check latest status against input form data.
suite.Equal(form.Status, latestStatus.Text)
suite.Equal(form.SpoilerText, latestStatus.ContentWarning)
suite.Equal(form.Sensitive, *latestStatus.Sensitive)
suite.Equal(form.Language, latestStatus.Language)
suite.Equal(len(status.EditIDs)+1, len(latestStatus.EditIDs))
suite.NotEqual(status.UpdatedAt(), latestStatus.UpdatedAt())
suite.Equal(form.MediaIDs, latestStatus.AttachmentIDs)
// Populate all historical edits for this status.
err = suite.state.DB.PopulateStatusEdits(ctx, latestStatus)
suite.NoError(err)
// Check previous status edit matches original status content.
previousEdit := latestStatus.Edits[len(latestStatus.Edits)-1]
suite.Equal(status.Content, previousEdit.Content)
suite.Equal(status.Text, previousEdit.Text)
suite.Equal(status.ContentWarning, previousEdit.ContentWarning)
suite.Equal(*status.Sensitive, *previousEdit.Sensitive)
suite.Equal(status.Language, previousEdit.Language)
suite.Equal(status.UpdatedAt(), previousEdit.CreatedAt)
suite.Equal(status.AttachmentIDs, previousEdit.AttachmentIDs)
}
func (suite *StatusEditTestSuite) TestEditOthersStatus1() {
// Create cancellable context to use for test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// Get a local account to use as test requester.
requester := suite.testAccounts["local_account_1"]
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
// Get remote accounts's status to attempt an edit on.
status := suite.testStatuses["remote_account_1_status_1"]
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
// Prepare an empty request form, this
// should be all we need to trigger it.
form := &apimodel.StatusEditRequest{}
// Attempt to edit other remote account's status, this should return an error.
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
suite.Nil(apiStatus)
suite.Equal(http.StatusNotFound, errWithCode.Code())
suite.Equal("status does not belong to requester", errWithCode.Error())
suite.Equal("Not Found: target status not found", errWithCode.Safe())
}
func (suite *StatusEditTestSuite) TestEditOthersStatus2() {
// Create cancellable context to use for test.
ctx, cncl := context.WithCancel(context.Background())
defer cncl()
// Get a local account to use as test requester.
requester := suite.testAccounts["local_account_1"]
requester, _ = suite.state.DB.GetAccountByID(ctx, requester.ID)
// Get other local accounts's status to attempt edit on.
status := suite.testStatuses["local_account_2_status_1"]
status, _ = suite.state.DB.GetStatusByID(ctx, status.ID)
// Prepare an empty request form, this
// should be all we need to trigger it.
form := &apimodel.StatusEditRequest{}
// Attempt to edit other local account's status, this should return an error.
apiStatus, errWithCode := suite.status.Edit(ctx, requester, status.ID, form)
suite.Nil(apiStatus)
suite.Equal(http.StatusNotFound, errWithCode.Code())
suite.Equal("status does not belong to requester", errWithCode.Error())
suite.Equal("Not Found: target status not found", errWithCode.Safe())
}
func TestStatusEditTestSuite(t *testing.T) {
suite.Run(t, new(StatusEditTestSuite))
}

View file

@ -19,47 +19,16 @@ package status
import (
"context"
"errors"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
// HistoryGet gets edit history for the target status, taking account of privacy settings and blocks etc.
// TODO: currently this just returns the latest version of the status.
func (p *Processor) HistoryGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) ([]*apimodel.StatusEdit, gtserror.WithCode) {
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
requestingAccount,
targetStatusID,
nil, // default freshness
)
if errWithCode != nil {
return nil, errWithCode
}
apiStatus, errWithCode := p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
if errWithCode != nil {
return nil, errWithCode
}
return []*apimodel.StatusEdit{
{
Content: apiStatus.Content,
SpoilerText: apiStatus.SpoilerText,
Sensitive: apiStatus.Sensitive,
CreatedAt: util.FormatISO8601(targetStatus.UpdatedAt),
Account: apiStatus.Account,
Poll: apiStatus.Poll,
MediaAttachments: apiStatus.MediaAttachments,
Emojis: apiStatus.Emojis,
},
}, nil
}
// Get gets the given status, taking account of privacy settings and blocks etc.
func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.Status, gtserror.WithCode) {
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
target, errWithCode := p.c.GetVisibleTargetStatus(ctx,
requestingAccount,
targetStatusID,
nil, // default freshness
@ -67,44 +36,25 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account
if errWithCode != nil {
return nil, errWithCode
}
return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus)
return p.c.GetAPIStatus(ctx, requestingAccount, target)
}
// SourceGet returns the *apimodel.StatusSource version of the targetStatusID.
// Status must belong to the requester, and must not be a boost.
func (p *Processor) SourceGet(ctx context.Context, requestingAccount *gtsmodel.Account, targetStatusID string) (*apimodel.StatusSource, gtserror.WithCode) {
targetStatus, errWithCode := p.c.GetVisibleTargetStatus(ctx,
requestingAccount,
targetStatusID,
nil, // default freshness
)
func (p *Processor) SourceGet(ctx context.Context, requester *gtsmodel.Account, statusID string) (*apimodel.StatusSource, gtserror.WithCode) {
status, errWithCode := p.c.GetOwnStatus(ctx, requester, statusID)
if errWithCode != nil {
return nil, errWithCode
}
// Redirect to wrapped status if boost.
targetStatus, errWithCode = p.c.UnwrapIfBoost(
ctx,
requestingAccount,
targetStatus,
)
if errWithCode != nil {
return nil, errWithCode
}
if targetStatus.AccountID != requestingAccount.ID {
err := gtserror.Newf(
"status %s does not belong to account %s",
targetStatusID, requestingAccount.ID,
if status.BoostOfID != "" {
return nil, gtserror.NewErrorNotFound(
errors.New("status is a boost wrapper"),
"target status not found",
)
return nil, gtserror.NewErrorNotFound(err)
}
statusSource, err := p.converter.StatusToAPIStatusSource(ctx, targetStatus)
if err != nil {
err = gtserror.Newf("error converting status: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return statusSource, nil
return &apimodel.StatusSource{
ID: status.ID,
Text: status.Text,
SpoilerText: status.ContentWarning,
}, nil
}

View file

@ -19,6 +19,7 @@ package status_test
import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/admin"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/filter/interaction"
@ -84,6 +85,7 @@ func (suite *StatusStandardTestSuite) SetupTest() {
suite.db = testrig.NewTestDB(&suite.state)
suite.typeConverter = typeutils.NewConverter(&suite.state)
suite.state.DB = suite.db
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
suite.tc = testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../../testrig/media"))
suite.storage = testrig.NewInMemoryStorage()

View file

@ -76,10 +76,11 @@ func (suite *NotificationTestSuite) TestStreamNotification() {
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.webp",
"header_static": "http://localhost:8080/assets/default_header.webp",
"header_description": "Flat gray background (default header).",
"followers_count": 0,
"following_count": 0,
"statuses_count": 3,
"last_status_at": "2021-09-11T09:40:37.000Z",
"statuses_count": 4,
"last_status_at": "2024-11-01",
"emojis": [],
"fields": []
}

View file

@ -54,6 +54,7 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() {
suite.Equal(`{
"id": "01FVW7JHQFSFK166WWKR8CBA6M",
"created_at": "2021-09-20T10:40:37.000Z",
"edited_at": null,
"in_reply_to_id": null,
"in_reply_to_account_id": null,
"sensitive": false,
@ -87,10 +88,11 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() {
"avatar_static": "",
"header": "http://localhost:8080/assets/default_header.webp",
"header_static": "http://localhost:8080/assets/default_header.webp",
"header_description": "Flat gray background (default header).",
"followers_count": 0,
"following_count": 0,
"statuses_count": 3,
"last_status_at": "2021-09-11T09:40:37.000Z",
"statuses_count": 4,
"last_status_at": "2024-11-01",
"emojis": [],
"fields": []
},

View file

@ -19,6 +19,7 @@ package stream_test
import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/admin"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
@ -50,6 +51,7 @@ func (suite *StreamTestSuite) SetupTest() {
suite.testTokens = testrig.NewTestTokens()
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
suite.oauthServer = testrig.NewTestOauthServer(suite.db)
suite.streamProcessor = stream.New(&suite.state, suite.oauthServer)

View file

@ -21,6 +21,7 @@ import (
"context"
"errors"
"fmt"
"net/url"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
"github.com/superseriousbusiness/gotosocial/internal/db"
@ -31,26 +32,21 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/internal/paging"
"github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *Processor) NotificationsGet(
ctx context.Context,
authed *oauth.Auth,
maxID string,
sinceID string,
minID string,
limit int,
types []string,
excludeTypes []string,
page *paging.Page,
types []gtsmodel.NotificationType,
excludeTypes []gtsmodel.NotificationType,
) (*apimodel.PageableResponse, gtserror.WithCode) {
notifs, err := p.state.DB.GetAccountNotifications(
ctx,
authed.Account.ID,
maxID,
sinceID,
minID,
limit,
page,
types,
excludeTypes,
)
@ -78,22 +74,15 @@ func (p *Processor) NotificationsGet(
compiledMutes := usermute.NewCompiledUserMuteList(mutes)
var (
items = make([]interface{}, 0, count)
nextMaxIDValue string
prevMinIDValue string
items = make([]interface{}, 0, count)
// Get the lowest and highest
// ID values, used for paging.
lo = notifs[count-1].ID
hi = notifs[0].ID
)
for i, n := range notifs {
// Set next + prev values before filtering and API
// converting, so caller can still page properly.
if i == count-1 {
nextMaxIDValue = n.ID
}
if i == 0 {
prevMinIDValue = n.ID
}
for _, n := range notifs {
visible, err := p.notifVisible(ctx, n, authed.Account)
if err != nil {
log.Debugf(ctx, "skipping notification %s because of an error checking notification visibility: %v", n.ID, err)
@ -115,13 +104,22 @@ func (p *Processor) NotificationsGet(
items = append(items, item)
}
return util.PackagePageableResponse(util.PageableResponseParams{
Items: items,
Path: "api/v1/notifications",
NextMaxIDValue: nextMaxIDValue,
PrevMinIDValue: prevMinIDValue,
Limit: limit,
})
// Build type query string.
query := make(url.Values)
for _, typ := range types {
query.Add("types[]", typ.String())
}
for _, typ := range excludeTypes {
query.Add("exclude_types[]", typ.String())
}
return paging.PackageResponse(paging.ResponseParams{
Items: items,
Path: "/api/v1/notifications",
Next: page.Next(lo, hi),
Prev: page.Prev(lo, hi),
Query: query,
}), nil
}
func (p *Processor) NotificationGet(ctx context.Context, account *gtsmodel.Account, targetNotifID string) (*apimodel.Notification, gtserror.WithCode) {

View file

@ -102,8 +102,8 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() {
requester = suite.testAccounts["local_account_1"]
maxID = ""
sinceID = ""
minID = "01F8MHAAY43M6RJ473VQFCVH36" // 1 before filteredStatus
limit = 10
minID = ""
limit = 100
local = false
filteredStatus = suite.testStatuses["admin_account_status_2"]
filteredStatusFound = false

View file

@ -19,6 +19,7 @@ package timeline_test
import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/admin"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -55,6 +56,7 @@ func (suite *TimelineStandardTestSuite) SetupTest() {
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
suite.timeline = timeline.New(
&suite.state,

View file

@ -19,6 +19,7 @@ package user_test
import (
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/admin"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/email"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@ -49,6 +50,7 @@ func (suite *UserStandardTestSuite) SetupTest() {
suite.db = testrig.NewTestDB(&suite.state)
suite.state.DB = suite.db
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
suite.sentEmails = make(map[string]string)
suite.emailSender = testrig.NewEmailSender("../../../web/template/", suite.sentEmails)

View file

@ -217,18 +217,23 @@ func (f *federate) CreatePollVote(ctx context.Context, poll *gtsmodel.Poll, vote
return err
}
// Convert vote to AS Create with vote choices as Objects.
create, err := f.converter.PollVoteToASCreate(ctx, vote)
// Convert vote to AS Creates with vote choices as Objects.
creates, err := f.converter.PollVoteToASCreates(ctx, vote)
if err != nil {
return gtserror.Newf("error converting to notes: %w", err)
}
// Send the Create via the Actor's outbox.
if _, err := f.FederatingActor().Send(ctx, outboxIRI, create); err != nil {
return gtserror.Newf("error sending Create activity via outbox %s: %w", outboxIRI, err)
var errs gtserror.MultiError
// Send each create activity.
actor := f.FederatingActor()
for _, create := range creates {
if _, err := actor.Send(ctx, outboxIRI, create); err != nil {
errs.Appendf("error sending Create activity via outbox %s: %w", outboxIRI, err)
}
}
return nil
return errs.Combine()
}
func (f *federate) DeleteStatus(ctx context.Context, status *gtsmodel.Status) error {

View file

@ -20,6 +20,7 @@ package workers
import (
"context"
"errors"
"net/url"
"time"
"codeberg.org/gruf/go-kv"
@ -144,6 +145,10 @@ func (p *Processor) ProcessFromFediAPI(ctx context.Context, fMsg *messages.FromF
// ACCEPT (pending) ANNOUNCE
case ap.ActivityAnnounce:
return p.fediAPI.AcceptAnnounce(ctx, fMsg)
// ACCEPT (remote) REPLY or ANNOUNCE
case ap.ObjectUnknown:
return p.fediAPI.AcceptRemoteStatus(ctx, fMsg)
}
// REJECT SOMETHING
@ -757,7 +762,7 @@ func (p *fediAPI) UpdateAccount(ctx context.Context, fMsg *messages.FromFediAPI)
account,
apubAcc,
// Force refresh within 10s window.
// Force refresh within 5s window.
//
// Missing account updates could be
// detrimental to federation if they
@ -823,6 +828,60 @@ func (p *fediAPI) AcceptReply(ctx context.Context, fMsg *messages.FromFediAPI) e
return nil
}
func (p *fediAPI) AcceptRemoteStatus(ctx context.Context, fMsg *messages.FromFediAPI) error {
// See if we can accept a remote
// status we don't have stored yet.
objectIRI, ok := fMsg.APObject.(*url.URL)
if !ok {
return gtserror.Newf("%T not parseable as *url.URL", fMsg.APObject)
}
acceptIRI := fMsg.APIRI
if acceptIRI == nil {
return gtserror.New("acceptIRI was nil")
}
// Assume we're accepting a status; create a
// barebones status for dereferencing purposes.
bareStatus := &gtsmodel.Status{
URI: objectIRI.String(),
ApprovedByURI: acceptIRI.String(),
}
// Call RefreshStatus() to process the provided
// barebones status and insert it into the database,
// if indeed it's actually a status URI we can fetch.
//
// This will also check whether the given AcceptIRI
// actually grants permission for this status.
status, _, err := p.federate.RefreshStatus(ctx,
fMsg.Receiving.Username,
bareStatus,
nil, nil,
)
if err != nil {
return gtserror.Newf("error processing accepted status %s: %w", bareStatus.URI, err)
}
// No error means it was indeed a remote status, and the
// given acceptIRI permitted it. Timeline and notify it.
if err := p.surface.timelineAndNotifyStatus(ctx, status); err != nil {
log.Errorf(ctx, "error timelining and notifying status: %v", err)
}
// Interaction counts changed on the interacted status;
// uncache the prepared version from all timelines.
if status.InReplyToID != "" {
p.surface.invalidateStatusFromTimelines(ctx, status.InReplyToID)
}
if status.BoostOfID != "" {
p.surface.invalidateStatusFromTimelines(ctx, status.BoostOfID)
}
return nil
}
func (p *fediAPI) AcceptAnnounce(ctx context.Context, fMsg *messages.FromFediAPI) error {
boost, ok := fMsg.GTSModel.(*gtsmodel.Status)
if !ok {
@ -858,17 +917,25 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg *messages.FromFediAPI)
return gtserror.Newf("cannot cast %T -> *gtsmodel.Status", fMsg.GTSModel)
}
var freshness *dereferencing.FreshnessWindow
// Cast the updated ActivityPub statusable object .
apStatus, _ := fMsg.APObject.(ap.Statusable)
if apStatus != nil {
// If an AP object was provided, we
// allow very fast refreshes that likely
// indicate a status edit after post.
freshness = dereferencing.Freshest
}
// Fetch up-to-date attach status attachments, etc.
status, _, err := p.federate.RefreshStatus(
ctx,
fMsg.Receiving.Username,
existing,
apStatus,
// Force refresh within 5min window.
dereferencing.Fresh,
freshness,
)
if err != nil {
log.Errorf(ctx, "error refreshing status: %v", err)

View file

@ -55,7 +55,6 @@ func (suite *FromFediAPITestSuite) TestProcessFederationAnnounce() {
announceStatus.URI = "https://example.org/some-announce-uri"
announceStatus.BoostOfURI = boostedStatus.URI
announceStatus.CreatedAt = time.Now()
announceStatus.UpdatedAt = time.Now()
announceStatus.AccountID = boostingAccount.ID
announceStatus.AccountURI = boostingAccount.URI
announceStatus.Account = boostingAccount

View file

@ -542,7 +542,7 @@ func getNotifyLockURI(
) string {
builder := strings.Builder{}
builder.WriteString("notification:?")
builder.WriteString("type=" + string(notificationType))
builder.WriteString("type=" + notificationType.String())
builder.WriteString("&target=" + targetAccount.URI)
builder.WriteString("&origin=" + originAccount.URI)
if statusID != "" {

View file

@ -89,7 +89,7 @@ func (suite *SurfaceNotifyTestSuite) TestSpamNotifs() {
notifs, err := testStructs.State.DB.GetAccountNotifications(
gtscontext.SetBarebones(ctx),
targetAccount.ID,
"", "", "", 0, nil, nil,
nil, nil, nil,
)
if err != nil {
suite.FailNow(err.Error())

View file

@ -75,6 +75,21 @@ func (u *utils) wipeStatus(
}
}
// Before handling media, ensure
// historic edits are populated.
if !status.EditsPopulated() {
var err error
// Fetch all historical edits of status from database.
status.Edits, err = u.state.DB.GetStatusEditsByIDs(
gtscontext.SetBarebones(ctx),
status.EditIDs,
)
if err != nil {
errs.Appendf("error getting status edits from database: %w", err)
}
}
// Either delete all attachments for this status,
// or simply detach + clean them separately later.
//
@ -83,20 +98,27 @@ func (u *utils) wipeStatus(
// status immediately (in case of delete + redraft).
if deleteAttachments {
// todo:u.state.DB.DeleteAttachmentsForStatus
for _, id := range status.AttachmentIDs {
for _, id := range status.AllAttachmentIDs() {
if err := u.media.Delete(ctx, id); err != nil {
errs.Appendf("error deleting media: %w", err)
}
}
} else {
// todo:u.state.DB.UnattachAttachmentsForStatus
for _, id := range status.AttachmentIDs {
for _, id := range status.AllAttachmentIDs() {
if _, err := u.media.Unattach(ctx, status.Account, id); err != nil {
errs.Appendf("error unattaching media: %w", err)
}
}
}
// Delete all historical edits of status.
if ids := status.EditIDs; len(ids) > 0 {
if err := u.state.DB.DeleteStatusEdits(ctx, ids); err != nil {
errs.Appendf("error deleting status edits: %w", err)
}
}
// Delete all mentions generated by this status.
// todo:u.state.DB.DeleteMentionsForStatus
for _, id := range status.MentionIDs {
@ -120,19 +142,20 @@ func (u *utils) wipeStatus(
errs.Appendf("error deleting status faves: %w", err)
}
if pollID := status.PollID; pollID != "" {
if id := status.PollID; id != "" {
// Delete this poll by ID from the database.
if err := u.state.DB.DeletePollByID(ctx, pollID); err != nil {
if err := u.state.DB.DeletePollByID(ctx, id); err != nil {
errs.Appendf("error deleting status poll: %w", err)
}
// Cancel any scheduled expiry task for poll.
_ = u.state.Workers.Scheduler.Cancel(pollID)
_ = u.state.Workers.Scheduler.Cancel(id)
}
// Get all boost of this status so that we can
// delete those boosts + remove them from timelines.
boosts, err := u.state.DB.GetStatusBoosts(
// We MUST set a barebones context here,
// as depending on where it came from the
// original BoostOf may already be gone.
@ -537,11 +560,7 @@ func (u *utils) requestFave(
}
// Create + store new interaction request.
req, err = typeutils.StatusFaveToInteractionRequest(ctx, fave)
if err != nil {
return gtserror.Newf("error creating interaction request: %w", err)
}
req = typeutils.StatusFaveToInteractionRequest(fave)
if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil {
return gtserror.Newf("db error storing interaction request: %w", err)
}
@ -584,11 +603,7 @@ func (u *utils) requestReply(
}
// Create + store interaction request.
req, err = typeutils.StatusToInteractionRequest(ctx, reply)
if err != nil {
return gtserror.Newf("error creating interaction request: %w", err)
}
req = typeutils.StatusToInteractionRequest(reply)
if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil {
return gtserror.Newf("db error storing interaction request: %w", err)
}
@ -631,11 +646,7 @@ func (u *utils) requestAnnounce(
}
// Create + store interaction request.
req, err = typeutils.StatusToInteractionRequest(ctx, boost)
if err != nil {
return gtserror.Newf("error creating interaction request: %w", err)
}
req = typeutils.StatusToInteractionRequest(boost)
if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil {
return gtserror.Newf("db error storing interaction request: %w", err)
}