mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-11-15 10:07:28 -06:00
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
0e137c0f2d
1759 changed files with 864109 additions and 314186 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: "this is the latest revision of the status, with a content-warning"</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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
>smodel.AdminAction{
|
||||
ID: actionID,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 := >smodel.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 := >smodel.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 := >smodel.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))
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 := >smodel.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,
|
||||
>smodel.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 := >smodel.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,
|
||||
>smodel.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 := >smodel.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,
|
||||
>smodel.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 := >smodel.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,
|
||||
>smodel.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 := >smodel.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,
|
||||
>smodel.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
324
internal/processing/admin/domainpermissiondraft.go
Normal file
324
internal/processing/admin/domainpermissiondraft.go
Normal 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 := >smodel.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)
|
||||
}
|
||||
159
internal/processing/admin/domainpermissionexclude.go
Normal file
159
internal/processing/admin/domainpermissionexclude.go
Normal 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 := >smodel.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)
|
||||
}
|
||||
378
internal/processing/admin/domainpermissionsubscription.go
Normal file
378
internal/processing/admin/domainpermissionsubscription.go
Normal 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 := >smodel.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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
351
internal/processing/status/common.go
Normal file
351
internal/processing/status/common.go
Normal 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 := >smodel.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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = >smodel.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
555
internal/processing/status/edit.go
Normal file
555
internal/processing/status/edit.go
Normal 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
|
||||
}
|
||||
544
internal/processing/status/edit_test.go
Normal file
544
internal/processing/status/edit_test.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 := >smodel.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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue