From 862cc9e3c48e0509380c458f32f6f7168ac9982c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 23 Sep 2024 10:40:01 +0200
Subject: [PATCH 01/12] [chore]: Bump github.com/prometheus/client_golang from
1.20.3 to 1.20.4 (#3327)
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.3 to 1.20.4.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.3...v1.20.4)
---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
go.mod | 2 +-
go.sum | 4 ++--
.../prometheus/client_golang/prometheus/histogram.go | 10 ++++++----
vendor/modules.txt | 2 +-
4 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/go.mod b/go.mod
index e14990b14..0abb76a2b 100644
--- a/go.mod
+++ b/go.mod
@@ -46,7 +46,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0
github.com/ncruces/go-sqlite3 v0.18.3
github.com/oklog/ulid v1.3.1
- github.com/prometheus/client_golang v1.20.3
+ github.com/prometheus/client_golang v1.20.4
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
diff --git a/go.sum b/go.sum
index 3c9d29dc7..5910b330d 100644
--- a/go.sum
+++ b/go.sum
@@ -468,8 +468,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
-github.com/prometheus/client_golang v1.20.3 h1:oPksm4K8B+Vt35tUhw6GbSNSgVlVSBH0qELP/7u83l4=
-github.com/prometheus/client_golang v1.20.3/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
+github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
+github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
diff --git a/vendor/github.com/prometheus/client_golang/prometheus/histogram.go b/vendor/github.com/prometheus/client_golang/prometheus/histogram.go
index 8a4f49a4c..519db348a 100644
--- a/vendor/github.com/prometheus/client_golang/prometheus/histogram.go
+++ b/vendor/github.com/prometheus/client_golang/prometheus/histogram.go
@@ -844,9 +844,7 @@ func (h *histogram) Write(out *dto.Metric) error {
}}
}
- // If exemplars are not configured, the cap will be 0.
- // So append is not needed in this case.
- if cap(h.nativeExemplars.exemplars) > 0 {
+ if h.nativeExemplars.isEnabled() {
h.nativeExemplars.Lock()
his.Exemplars = append(his.Exemplars, h.nativeExemplars.exemplars...)
h.nativeExemplars.Unlock()
@@ -1665,6 +1663,10 @@ type nativeExemplars struct {
exemplars []*dto.Exemplar
}
+func (n *nativeExemplars) isEnabled() bool {
+ return n.ttl != -1
+}
+
func makeNativeExemplars(ttl time.Duration, maxCount int) nativeExemplars {
if ttl == 0 {
ttl = 5 * time.Minute
@@ -1686,7 +1688,7 @@ func makeNativeExemplars(ttl time.Duration, maxCount int) nativeExemplars {
}
func (n *nativeExemplars) addExemplar(e *dto.Exemplar) {
- if n.ttl == -1 {
+ if !n.isEnabled() {
return
}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index adaadae51..f94c973d0 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -561,7 +561,7 @@ github.com/pkg/errors
# github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
## explicit
github.com/pmezard/go-difflib/difflib
-# github.com/prometheus/client_golang v1.20.3
+# github.com/prometheus/client_golang v1.20.4
## explicit; go 1.20
github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil
github.com/prometheus/client_golang/internal/github.com/golang/gddo/httputil/header
From 964262b169bef4f4804f625306981bbf3b8d6635 Mon Sep 17 00:00:00 2001
From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>
Date: Mon, 23 Sep 2024 11:36:56 +0000
Subject: [PATCH 02/12] [chore] header filter improvements (#3329)
* add error message to gin context on header blocked or not allowed
* remove the unused header filter tracking code (leaving OTEL TODOs in place)
* appease the linter
---
internal/middleware/headerfilter.go | 61 +++++++++++------------------
1 file changed, 22 insertions(+), 39 deletions(-)
diff --git a/internal/middleware/headerfilter.go b/internal/middleware/headerfilter.go
index 18c9d1e67..5a17a8a71 100644
--- a/internal/middleware/headerfilter.go
+++ b/internal/middleware/headerfilter.go
@@ -18,7 +18,7 @@
package middleware
import (
- "sync"
+ "errors"
"github.com/gin-gonic/gin"
"github.com/superseriousbusiness/gotosocial/internal/config"
@@ -29,25 +29,11 @@
)
var (
- allowMatches = matchstats{m: make(map[string]uint64)}
- blockMatches = matchstats{m: make(map[string]uint64)}
+ // errors set on gin context by header filter middleware.
+ errHeaderNotAllowed = errors.New("header did not match allow filter")
+ errHeaderBlocked = errors.New("header matched block filter")
)
-// matchstats is a simple statistics
-// counter for header filter matches.
-// TODO: replace with otel.
-type matchstats struct {
- m map[string]uint64
- l sync.Mutex
-}
-
-func (m *matchstats) Add(hdr, regex string) {
- m.l.Lock()
- key := hdr + ":" + regex
- m.m[key]++
- m.l.Unlock()
-}
-
// HeaderFilter returns a gin middleware handler that provides HTTP
// request blocking (filtering) based on database allow / block filters.
func HeaderFilter(state *state.State) gin.HandlerFunc {
@@ -83,6 +69,7 @@ func headerFilterAllowMode(state *state.State) func(c *gin.Context) {
}
if block {
+ _ = c.Error(errHeaderBlocked)
respondBlocked(c)
return
}
@@ -95,6 +82,7 @@ func headerFilterAllowMode(state *state.State) func(c *gin.Context) {
}
if notAllow {
+ _ = c.Error(errHeaderNotAllowed)
respondBlocked(c)
return
}
@@ -129,6 +117,7 @@ func headerFilterBlockMode(state *state.State) func(c *gin.Context) {
}
if block {
+ _ = c.Error(errHeaderBlocked)
respondBlocked(c)
return
}
@@ -146,7 +135,7 @@ func isHeaderBlocked(state *state.State, c *gin.Context) (bool, error) {
)
// Perform an explicit is-blocked check on request header.
- key, expr, err := state.DB.BlockHeaderRegularMatch(ctx, hdr)
+ key, _, err := state.DB.BlockHeaderRegularMatch(ctx, hdr)
switch err {
case nil:
break
@@ -161,12 +150,10 @@ func isHeaderBlocked(state *state.State, c *gin.Context) (bool, error) {
}
if key != "" {
- if expr != "" {
- // Increment block matches stat.
- // TODO: replace expvar with build
- // taggable metrics types in State{}.
- blockMatches.Add(key, expr)
- }
+ // if expr != "" {
+ // // TODO: replace expvar with build
+ // // taggable metrics types in State{}.
+ // }
// A header was matched against!
// i.e. this request is blocked.
@@ -183,7 +170,7 @@ func isHeaderAllowed(state *state.State, c *gin.Context) (bool, error) {
)
// Perform an explicit is-allowed check on request header.
- key, expr, err := state.DB.AllowHeaderRegularMatch(ctx, hdr)
+ key, _, err := state.DB.AllowHeaderRegularMatch(ctx, hdr)
switch err {
case nil:
break
@@ -198,12 +185,10 @@ func isHeaderAllowed(state *state.State, c *gin.Context) (bool, error) {
}
if key != "" {
- if expr != "" {
- // Increment allow matches stat.
- // TODO: replace expvar with build
- // taggable metrics types in State{}.
- allowMatches.Add(key, expr)
- }
+ // if expr != "" {
+ // // TODO: replace expvar with build
+ // // taggable metrics types in State{}.
+ // }
// A header was matched against!
// i.e. this request is allowed.
@@ -220,7 +205,7 @@ func isHeaderNotAllowed(state *state.State, c *gin.Context) (bool, error) {
)
// Perform an explicit is-NOT-allowed check on request header.
- key, expr, err := state.DB.AllowHeaderInverseMatch(ctx, hdr)
+ key, _, err := state.DB.AllowHeaderInverseMatch(ctx, hdr)
switch err {
case nil:
break
@@ -235,12 +220,10 @@ func isHeaderNotAllowed(state *state.State, c *gin.Context) (bool, error) {
}
if key != "" {
- if expr != "" {
- // Increment allow matches stat.
- // TODO: replace expvar with build
- // taggable metrics types in State{}.
- allowMatches.Add(key, expr)
- }
+ // if expr != "" {
+ // // TODO: replace expvar with build
+ // // taggable metrics types in State{}.
+ // }
// A header was matched against!
// i.e. request is NOT allowed.
From 4592e290872e0208d03189aea4f410cd47a5dc1d Mon Sep 17 00:00:00 2001
From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>
Date: Mon, 23 Sep 2024 11:53:42 +0000
Subject: [PATCH 03/12] [chore] local instance count query caching, improved
status context endpoint logging, don't log ErrHideStatus when timelining
(#3330)
* ensure that errors checking status visibility / converting aren't dropped
* add some more context to error messages
* include calling function name in log entries
* don't error on timelining hidden status
* further code to ignore statusfilter.ErrHideStatus type errors
* remove unused error type
* add local instance status / domain / user counts
* add checks for localhost
* rename from InstanceCounts to LocalInstance
* improved code comment
---
internal/cache/db.go | 17 ++++-
internal/cache/invalidate.go | 14 ++++
internal/db/bundb/instance.go | 55 +++++++++++++-
internal/filter/visibility/status.go | 42 +++++-----
internal/httpclient/client.go | 4 -
internal/processing/common/account.go | 17 +++--
internal/processing/common/status.go | 76 ++++++++++++++++++-
internal/processing/status/context.go | 61 +++++----------
.../processing/workers/surfacetimeline.go | 19 +++--
9 files changed, 214 insertions(+), 91 deletions(-)
diff --git a/internal/cache/db.go b/internal/cache/db.go
index fe9085613..dd4e8b212 100644
--- a/internal/cache/db.go
+++ b/internal/cache/db.go
@@ -18,6 +18,8 @@
package cache
import (
+ "sync/atomic"
+
"codeberg.org/gruf/go-structr"
"github.com/superseriousbusiness/gotosocial/internal/cache/domain"
"github.com/superseriousbusiness/gotosocial/internal/config"
@@ -136,6 +138,14 @@ type DBCaches struct {
// Instance provides access to the gtsmodel Instance database cache.
Instance StructCache[*gtsmodel.Instance]
+ // LocalInstance provides caching for
+ // simple + common local instance queries.
+ LocalInstance struct {
+ Domains atomic.Pointer[int]
+ Statuses atomic.Pointer[int]
+ Users atomic.Pointer[int]
+ }
+
// InteractionRequest provides access to the gtsmodel InteractionRequest database cache.
InteractionRequest StructCache[*gtsmodel.InteractionRequest]
@@ -849,9 +859,10 @@ func (c *Caches) initInstance() {
{Fields: "ID"},
{Fields: "Domain"},
},
- MaxSize: cap,
- IgnoreErr: ignoreErrors,
- Copy: copyF,
+ MaxSize: cap,
+ IgnoreErr: ignoreErrors,
+ Copy: copyF,
+ Invalidate: c.OnInvalidateInstance,
})
}
diff --git a/internal/cache/invalidate.go b/internal/cache/invalidate.go
index ca12e412c..9b42e88f6 100644
--- a/internal/cache/invalidate.go
+++ b/internal/cache/invalidate.go
@@ -19,6 +19,7 @@
import (
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
)
// Below are cache invalidation hooks between other caches,
@@ -178,6 +179,11 @@ func (c *Caches) OnInvalidateFollowRequest(followReq *gtsmodel.FollowRequest) {
)
}
+func (c *Caches) OnInvalidateInstance(instance *gtsmodel.Instance) {
+ // Invalidate the local domains count.
+ c.DB.LocalInstance.Domains.Store(nil)
+}
+
func (c *Caches) OnInvalidateList(list *gtsmodel.List) {
// Invalidate list IDs cache.
c.DB.ListIDs.Invalidate(
@@ -255,6 +261,11 @@ func (c *Caches) OnInvalidateStatus(status *gtsmodel.Status) {
// Invalidate cache of attached poll ID.
c.DB.Poll.Invalidate("ID", status.PollID)
}
+
+ if util.PtrOrZero(status.Local) {
+ // Invalidate the local statuses count.
+ c.DB.LocalInstance.Statuses.Store(nil)
+ }
}
func (c *Caches) OnInvalidateStatusBookmark(bookmark *gtsmodel.StatusBookmark) {
@@ -271,6 +282,9 @@ func (c *Caches) OnInvalidateUser(user *gtsmodel.User) {
// Invalidate local account ID cached visibility.
c.Visibility.Invalidate("ItemID", user.AccountID)
c.Visibility.Invalidate("RequesterID", user.AccountID)
+
+ // Invalidate the local users count.
+ c.DB.LocalInstance.Users.Store(nil)
}
func (c *Caches) OnInvalidateUserMute(mute *gtsmodel.UserMute) {
diff --git a/internal/db/bundb/instance.go b/internal/db/bundb/instance.go
index 008b6c8f3..419951253 100644
--- a/internal/db/bundb/instance.go
+++ b/internal/db/bundb/instance.go
@@ -39,6 +39,15 @@ type instanceDB struct {
}
func (i *instanceDB) CountInstanceUsers(ctx context.Context, domain string) (int, error) {
+ localhost := (domain == config.GetHost() || domain == config.GetAccountDomain())
+
+ if localhost {
+ // Check for a cached instance user count, if so return this.
+ if n := i.state.Caches.DB.LocalInstance.Users.Load(); n != nil {
+ return *n, nil
+ }
+ }
+
q := i.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("accounts"), bun.Ident("account")).
@@ -46,7 +55,7 @@ func (i *instanceDB) CountInstanceUsers(ctx context.Context, domain string) (int
Where("? != ?", bun.Ident("account.username"), domain).
Where("? IS NULL", bun.Ident("account.suspended_at"))
- if domain == config.GetHost() || domain == config.GetAccountDomain() {
+ if localhost {
// If the domain is *this* domain, just
// count where the domain field is null.
q = q.Where("? IS NULL", bun.Ident("account.domain"))
@@ -58,15 +67,30 @@ func (i *instanceDB) CountInstanceUsers(ctx context.Context, domain string) (int
if err != nil {
return 0, err
}
+
+ if localhost {
+ // Update cached instance users account value.
+ i.state.Caches.DB.LocalInstance.Users.Store(&count)
+ }
+
return count, nil
}
func (i *instanceDB) CountInstanceStatuses(ctx context.Context, domain string) (int, error) {
+ localhost := (domain == config.GetHost() || domain == config.GetAccountDomain())
+
+ if localhost {
+ // Check for a cached instance statuses count, if so return this.
+ if n := i.state.Caches.DB.LocalInstance.Statuses.Load(); n != nil {
+ return *n, nil
+ }
+ }
+
q := i.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("statuses"), bun.Ident("status"))
- if domain == config.GetHost() || domain == config.GetAccountDomain() {
+ if localhost {
// if the domain is *this* domain, just count where local is true
q = q.Where("? = ?", bun.Ident("status.local"), true)
} else {
@@ -83,15 +107,30 @@ func (i *instanceDB) CountInstanceStatuses(ctx context.Context, domain string) (
if err != nil {
return 0, err
}
+
+ if localhost {
+ // Update cached instance statuses account value.
+ i.state.Caches.DB.LocalInstance.Statuses.Store(&count)
+ }
+
return count, nil
}
func (i *instanceDB) CountInstanceDomains(ctx context.Context, domain string) (int, error) {
+ localhost := (domain == config.GetHost() || domain == config.GetAccountDomain())
+
+ if localhost {
+ // Check for a cached instance domains count, if so return this.
+ if n := i.state.Caches.DB.LocalInstance.Domains.Load(); n != nil {
+ return *n, nil
+ }
+ }
+
q := i.db.
NewSelect().
TableExpr("? AS ?", bun.Ident("instances"), bun.Ident("instance"))
- if domain == config.GetHost() {
+ if localhost {
// if the domain is *this* domain, just count other instances it knows about
// exclude domains that are blocked
q = q.
@@ -106,6 +145,12 @@ func (i *instanceDB) CountInstanceDomains(ctx context.Context, domain string) (i
if err != nil {
return 0, err
}
+
+ if localhost {
+ // Update cached instance domains account value.
+ i.state.Caches.DB.LocalInstance.Domains.Store(&count)
+ }
+
return count, nil
}
@@ -215,13 +260,15 @@ func (i *instanceDB) PopulateInstance(ctx context.Context, instance *gtsmodel.In
}
func (i *instanceDB) PutInstance(ctx context.Context, instance *gtsmodel.Instance) error {
- // Normalize the domain as punycode
var err error
+
+ // Normalize the domain as punycode
instance.Domain, err = util.Punify(instance.Domain)
if err != nil {
return gtserror.Newf("error punifying domain %s: %w", instance.Domain, err)
}
+ // Store the new instance model in database, invalidating cache.
return i.state.Caches.DB.Instance.Store(instance, func() error {
_, err := i.db.NewInsert().Model(instance).Exec(ctx)
return err
diff --git a/internal/filter/visibility/status.go b/internal/filter/visibility/status.go
index be59e800e..a0f971464 100644
--- a/internal/filter/visibility/status.go
+++ b/internal/filter/visibility/status.go
@@ -104,18 +104,20 @@ func (f *Filter) isStatusVisible(
return false, nil
}
- if util.PtrOrValue(status.PendingApproval, false) {
+ if util.PtrOrZero(status.PendingApproval) {
// Use a different visibility heuristic
// for pending approval statuses.
- return f.isPendingStatusVisible(ctx,
+ return isPendingStatusVisible(
requester, status,
- )
+ ), nil
}
if requester == nil {
// Use a different visibility
// heuristic for unauthed requests.
- return f.isStatusVisibleUnauthed(ctx, status)
+ return f.isStatusVisibleUnauthed(
+ ctx, status,
+ )
}
/*
@@ -210,45 +212,42 @@ func (f *Filter) isStatusVisible(
}
}
-func (f *Filter) isPendingStatusVisible(
- _ context.Context,
- requester *gtsmodel.Account,
- status *gtsmodel.Status,
-) (bool, error) {
+// isPendingStatusVisible returns whether a status pending approval is visible to requester.
+func isPendingStatusVisible(requester *gtsmodel.Account, status *gtsmodel.Status) bool {
if requester == nil {
// Any old tom, dick, and harry can't
// see pending-approval statuses,
// no matter what their visibility.
- return false, nil
+ return false
}
if status.AccountID == requester.ID {
// This is requester's status,
// so they can always see it.
- return true, nil
+ return true
}
if status.InReplyToAccountID == requester.ID {
// This status replies to requester,
// so they can always see it (else
// they can't approve it).
- return true, nil
+ return true
}
if status.BoostOfAccountID == requester.ID {
// This status boosts requester,
// so they can always see it.
- return true, nil
+ return true
}
- // Nobody else can see this.
- return false, nil
+ // Nobody else
+ // can see this.
+ return false
}
-func (f *Filter) isStatusVisibleUnauthed(
- ctx context.Context,
- status *gtsmodel.Status,
-) (bool, error) {
+// isStatusVisibleUnauthed returns whether status is visible without any unauthenticated account.
+func (f *Filter) isStatusVisibleUnauthed(ctx context.Context, status *gtsmodel.Status) (bool, error) {
+
// For remote accounts, only show
// Public statuses via the web.
if status.Account.IsRemote() {
@@ -275,8 +274,7 @@ func (f *Filter) isStatusVisibleUnauthed(
}
}
- webVisibility := status.Account.Settings.WebVisibility
- switch webVisibility {
+ switch webvis := status.Account.Settings.WebVisibility; webvis {
// public_only: status must be Public.
case gtsmodel.VisibilityPublic:
@@ -296,7 +294,7 @@ func (f *Filter) isStatusVisibleUnauthed(
default:
return false, gtserror.Newf(
"unrecognized web visibility for account %s: %s",
- status.Account.ID, webVisibility,
+ status.Account.ID, webvis,
)
}
}
diff --git a/internal/httpclient/client.go b/internal/httpclient/client.go
index 30ef0b04d..8a5f51c21 100644
--- a/internal/httpclient/client.go
+++ b/internal/httpclient/client.go
@@ -48,9 +48,6 @@
// ErrReservedAddr is returned if a dialed address resolves to an IP within a blocked or reserved net.
ErrReservedAddr = errors.New("dial within blocked / reserved IP range")
-
- // ErrBodyTooLarge is returned when a received response body is above predefined limit (default 40MB).
- ErrBodyTooLarge = errors.New("body size too large")
)
// Config provides configuration details for setting up a new
@@ -302,7 +299,6 @@ func (c *Client) do(r *Request) (rsp *http.Response, retry bool, err error) {
if errorsv2.IsV2(err,
context.DeadlineExceeded,
context.Canceled,
- ErrBodyTooLarge,
ErrReservedAddr,
) {
// Non-retryable errors.
diff --git a/internal/processing/common/account.go b/internal/processing/common/account.go
index 05e974513..ae26e4ebd 100644
--- a/internal/processing/common/account.go
+++ b/internal/processing/common/account.go
@@ -42,6 +42,7 @@ func (p *Processor) GetTargetAccountBy(
// Fetch the target account from db.
target, err := getTargetFromDB()
if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err := gtserror.Newf("error getting from db: %w", err)
return nil, false, gtserror.NewErrorInternalError(err)
}
@@ -57,6 +58,7 @@ func (p *Processor) GetTargetAccountBy(
// Check whether target account is visible to requesting account.
visible, err = p.visFilter.AccountVisible(ctx, requester, target)
if err != nil {
+ err := gtserror.Newf("error checking visibility: %w", err)
return nil, false, gtserror.NewErrorInternalError(err)
}
@@ -128,7 +130,8 @@ func (p *Processor) GetVisibleTargetAccount(
return target, nil
}
-// GetAPIAccount fetches the appropriate API account model depending on whether requester = target.
+// GetAPIAccount fetches the appropriate API account
+// model depending on whether requester = target.
func (p *Processor) GetAPIAccount(
ctx context.Context,
requester *gtsmodel.Account,
@@ -148,14 +151,15 @@ func (p *Processor) GetAPIAccount(
}
if err != nil {
- err := gtserror.Newf("error converting account: %w", err)
+ err := gtserror.Newf("error converting: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiAcc, nil
}
-// GetAPIAccountBlocked fetches the limited "blocked" account model for given target.
+// GetAPIAccountBlocked fetches the limited
+// "blocked" account model for given target.
func (p *Processor) GetAPIAccountBlocked(
ctx context.Context,
targetAcc *gtsmodel.Account,
@@ -165,7 +169,7 @@ func (p *Processor) GetAPIAccountBlocked(
) {
apiAccount, err := p.converter.AccountToAPIAccountBlocked(ctx, targetAcc)
if err != nil {
- err = gtserror.Newf("error converting account: %w", err)
+ err := gtserror.Newf("error converting: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiAccount, nil
@@ -182,7 +186,7 @@ func (p *Processor) GetAPIAccountSensitive(
) {
apiAccount, err := p.converter.AccountToAPIAccountSensitive(ctx, targetAcc)
if err != nil {
- err = gtserror.Newf("error converting account: %w", err)
+ err := gtserror.Newf("error converting: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiAccount, nil
@@ -226,8 +230,7 @@ func (p *Processor) getVisibleAPIAccounts(
) []*apimodel.Account {
// Start new log entry with
// the above calling func's name.
- l := log.
- WithContext(ctx).
+ l := log.WithContext(ctx).
WithField("caller", log.Caller(calldepth+1))
// Preallocate slice according to expected length.
diff --git a/internal/processing/common/status.go b/internal/processing/common/status.go
index a1d432eb0..dd83a2cc5 100644
--- a/internal/processing/common/status.go
+++ b/internal/processing/common/status.go
@@ -25,6 +25,7 @@
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
+ "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log"
@@ -50,6 +51,7 @@ func (p *Processor) GetTargetStatusBy(
// Fetch the target status from db.
target, err := getTargetFromDB()
if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ err := gtserror.Newf("error getting from db: %w", err)
return nil, false, gtserror.NewErrorInternalError(err)
}
@@ -65,6 +67,7 @@ func (p *Processor) GetTargetStatusBy(
// Check whether target status is visible to requesting account.
visible, err = p.visFilter.StatusVisible(ctx, requester, target)
if err != nil {
+ err := gtserror.Newf("error checking visibility: %w", err)
return nil, false, gtserror.NewErrorInternalError(err)
}
@@ -174,14 +177,83 @@ func (p *Processor) GetAPIStatus(
apiStatus *apimodel.Status,
errWithCode gtserror.WithCode,
) {
- apiStatus, err := p.converter.StatusToAPIStatus(ctx, target, requester, statusfilter.FilterContextNone, nil, nil)
+ apiStatus, err := p.converter.StatusToAPIStatus(ctx,
+ target,
+ requester,
+ statusfilter.FilterContextNone,
+ nil,
+ nil,
+ )
if err != nil {
- err = gtserror.Newf("error converting status: %w", err)
+ err := gtserror.Newf("error converting: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
return apiStatus, nil
}
+// GetVisibleAPIStatuses converts a slice of statuses to API
+// model statuses, filtering according to visibility to requester
+// along with given filter context, filters and user mutes.
+//
+// Please note that all errors will be logged at ERROR level,
+// but will not be returned. Callers are likely to run into
+// show-stopping errors in the lead-up to this function.
+func (p *Processor) GetVisibleAPIStatuses(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ statuses []*gtsmodel.Status,
+ filterContext statusfilter.FilterContext,
+ filters []*gtsmodel.Filter,
+ userMutes []*gtsmodel.UserMute,
+) []apimodel.Status {
+
+ // Start new log entry with
+ // the calling function name
+ // as a field in each entry.
+ l := log.WithContext(ctx).
+ WithField("caller", log.Caller(3))
+
+ // Compile mutes to useable user mutes for type converter.
+ compUserMutes := usermute.NewCompiledUserMuteList(userMutes)
+
+ // Iterate filtered statuses for conversion to API model.
+ apiStatuses := make([]apimodel.Status, 0, len(statuses))
+ for _, status := range statuses {
+
+ // Check whether status is visible to requester.
+ visible, err := p.visFilter.StatusVisible(ctx,
+ requester,
+ status,
+ )
+ if err != nil {
+ l.Errorf("error checking visibility: %v", err)
+ continue
+ }
+
+ if !visible {
+ continue
+ }
+
+ // Convert to API status, taking mute / filter into account.
+ apiStatus, err := p.converter.StatusToAPIStatus(ctx,
+ status,
+ requester,
+ filterContext,
+ filters,
+ compUserMutes,
+ )
+ if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) {
+ l.Errorf("error converting: %v", err)
+ continue
+ }
+
+ // Append converted status to return slice.
+ apiStatuses = append(apiStatuses, *apiStatus)
+ }
+
+ return apiStatuses
+}
+
// InvalidateTimelinedStatus is a shortcut function for invalidating the cached
// representation one status in the home timeline and all list timelines of the
// given accountID. It should only be called in cases where a status update
diff --git a/internal/processing/status/context.go b/internal/processing/status/context.go
index 9f3a7d089..19c6cac18 100644
--- a/internal/processing/status/context.go
+++ b/internal/processing/status/context.go
@@ -24,7 +24,6 @@
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
statusfilter "github.com/superseriousbusiness/gotosocial/internal/filter/status"
- "github.com/superseriousbusiness/gotosocial/internal/filter/usermute"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
@@ -308,22 +307,7 @@ func (p *Processor) ContextGet(
return nil, gtserror.NewErrorInternalError(err)
}
- convert := func(
- ctx context.Context,
- status *gtsmodel.Status,
- requestingAccount *gtsmodel.Account,
- ) (*apimodel.Status, error) {
- return p.converter.StatusToAPIStatus(
- ctx,
- status,
- requestingAccount,
- statusfilter.FilterContextThread,
- filters,
- usermute.NewCompiledUserMuteList(mutes),
- )
- }
-
- // Retrieve the thread context.
+ // Retrieve the full thread context.
threadContext, errWithCode := p.contextGet(
ctx,
requester,
@@ -333,34 +317,27 @@ func (p *Processor) ContextGet(
return nil, errWithCode
}
- apiContext := &apimodel.ThreadContext{
- Ancestors: make([]apimodel.Status, 0, len(threadContext.ancestors)),
- Descendants: make([]apimodel.Status, 0, len(threadContext.descendants)),
- }
+ var apiContext apimodel.ThreadContext
- // Convert ancestors + filter
- // out ones that aren't visible.
- for _, status := range threadContext.ancestors {
- if v, err := p.visFilter.StatusVisible(ctx, requester, status); err == nil && v {
- status, err := convert(ctx, status, requester)
- if err == nil {
- apiContext.Ancestors = append(apiContext.Ancestors, *status)
- }
- }
- }
+ // Convert and filter the thread context ancestors.
+ apiContext.Ancestors = p.c.GetVisibleAPIStatuses(ctx,
+ requester,
+ threadContext.ancestors,
+ statusfilter.FilterContextThread,
+ filters,
+ mutes,
+ )
- // Convert descendants + filter
- // out ones that aren't visible.
- for _, status := range threadContext.descendants {
- if v, err := p.visFilter.StatusVisible(ctx, requester, status); err == nil && v {
- status, err := convert(ctx, status, requester)
- if err == nil {
- apiContext.Descendants = append(apiContext.Descendants, *status)
- }
- }
- }
+ // Convert and filter the thread context descendants
+ apiContext.Descendants = p.c.GetVisibleAPIStatuses(ctx,
+ requester,
+ threadContext.descendants,
+ statusfilter.FilterContextThread,
+ filters,
+ mutes,
+ )
- return apiContext, nil
+ return &apiContext, nil
}
// WebContextGet is like ContextGet, but is explicitly
diff --git a/internal/processing/workers/surfacetimeline.go b/internal/processing/workers/surfacetimeline.go
index 90cb1fed3..b071bd72e 100644
--- a/internal/processing/workers/surfacetimeline.go
+++ b/internal/processing/workers/surfacetimeline.go
@@ -384,8 +384,9 @@ func (s *Surface) timelineStatus(
) (bool, error) {
// Ingest status into given timeline using provided function.
- if inserted, err := ingest(ctx, timelineID, status); err != nil {
- err = gtserror.Newf("error ingesting status %s: %w", status.ID, err)
+ if inserted, err := ingest(ctx, timelineID, status); err != nil &&
+ !errors.Is(err, statusfilter.ErrHideStatus) {
+ err := gtserror.Newf("error ingesting status %s: %w", status.ID, err)
return false, err
} else if !inserted {
// Nothing more to do.
@@ -400,15 +401,19 @@ func (s *Surface) timelineStatus(
filters,
mutes,
)
- if err != nil {
- err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
+ if err != nil && !errors.Is(err, statusfilter.ErrHideStatus) {
+ err := gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err)
return true, err
}
- // The status was inserted so stream it to the user.
- s.Stream.Update(ctx, account, apiStatus, streamType)
+ if apiStatus != nil {
+ // The status was inserted so stream it to the user.
+ s.Stream.Update(ctx, account, apiStatus, streamType)
+ return true, nil
+ }
- return true, nil
+ // Status was hidden.
+ return false, nil
}
// timelineAndNotifyStatusForTagFollowers inserts the status into the
From 2f13b72e2e6911da43feebd28160a41bd6feb507 Mon Sep 17 00:00:00 2001
From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com>
Date: Mon, 23 Sep 2024 12:01:12 +0000
Subject: [PATCH 04/12] [chore] add nometrics build tagging to metrics API
endpoint (#3331)
* add nometrics API endpoint code, to ensure prometheus NEVER compiled in unless wanted
* whoops, fix build tagging
---
internal/api/metrics/metrics.go | 2 ++
internal/api/metrics/no_metrics.go | 31 ++++++++++++++++++++++++++++++
2 files changed, 33 insertions(+)
create mode 100644 internal/api/metrics/no_metrics.go
diff --git a/internal/api/metrics/metrics.go b/internal/api/metrics/metrics.go
index af774d5e8..bbca861ef 100644
--- a/internal/api/metrics/metrics.go
+++ b/internal/api/metrics/metrics.go
@@ -15,6 +15,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+//go:build !nometrics
+
package metrics
import (
diff --git a/internal/api/metrics/no_metrics.go b/internal/api/metrics/no_metrics.go
new file mode 100644
index 000000000..ae7fc7ce7
--- /dev/null
+++ b/internal/api/metrics/no_metrics.go
@@ -0,0 +1,31 @@
+// 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 .
+
+//go:build nometrics
+
+package metrics
+
+import (
+ "github.com/gin-gonic/gin"
+)
+
+type Module struct{}
+
+func New() *Module { return &Module{} }
+
+func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) {
+}
From 1ce854358def5f04b7c3b73418ab56bb58512634 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Mon, 23 Sep 2024 14:42:19 +0200
Subject: [PATCH 05/12] [feature] Show info for pending replies, allow implicit
accept of pending replies (#3322)
* [feature] Allow implicit accept of pending replies
* update wording
---
internal/api/client/statuses/status_test.go | 114 +++
.../api/client/statuses/statusboost_test.go | 735 +++++++++++++-----
.../api/client/statuses/statuscreate_test.go | 92 +--
.../api/client/statuses/statusfave_test.go | 310 ++++++--
internal/processing/processor.go | 2 +-
internal/processing/status/boost.go | 18 +
internal/processing/status/create.go | 17 +
internal/processing/status/fave.go | 22 +-
internal/processing/status/status.go | 6 +-
internal/processing/status/status_test.go | 3 +
internal/processing/status/util.go | 72 ++
internal/typeutils/internaltofrontend.go | 121 ++-
internal/typeutils/internaltofrontend_test.go | 125 +++
internal/typeutils/util.go | 44 ++
web/source/settings/views/user/router.tsx | 14 +-
15 files changed, 1318 insertions(+), 377 deletions(-)
create mode 100644 internal/processing/status/util.go
diff --git a/internal/api/client/statuses/status_test.go b/internal/api/client/statuses/status_test.go
index a979f0c00..1a92276a1 100644
--- a/internal/api/client/statuses/status_test.go
+++ b/internal/api/client/statuses/status_test.go
@@ -18,6 +18,12 @@
package statuses_test
import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http/httptest"
+ "strings"
+
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
"github.com/superseriousbusiness/gotosocial/internal/db"
@@ -25,6 +31,7 @@
"github.com/superseriousbusiness/gotosocial/internal/federation"
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/state"
@@ -59,6 +66,113 @@ type StatusStandardTestSuite struct {
statusModule *statuses.Module
}
+// Normalizes a status response to a determinate
+// form, and pretty-prints it to JSON.
+func (suite *StatusStandardTestSuite) parseStatusResponse(
+ recorder *httptest.ResponseRecorder,
+) (string, *httptest.ResponseRecorder) {
+
+ result := recorder.Result()
+ defer result.Body.Close()
+
+ data, err := io.ReadAll(result.Body)
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ rawMap := make(map[string]any)
+ if err := json.Unmarshal(data, &rawMap); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // Make status fields determinate.
+ suite.determinateStatus(rawMap)
+
+ // For readability, don't
+ // escape HTML, and indent json.
+ out := new(bytes.Buffer)
+ enc := json.NewEncoder(out)
+ enc.SetEscapeHTML(false)
+ enc.SetIndent("", " ")
+
+ if err := enc.Encode(&rawMap); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ return strings.TrimSpace(out.String()), recorder
+}
+
+func (suite *StatusStandardTestSuite) determinateStatus(rawMap map[string]any) {
+ // Replace any fields from the raw map that
+ // aren't determinate (date, id, url, etc).
+ if _, ok := rawMap["id"]; ok {
+ rawMap["id"] = id.Highest
+ }
+
+ if _, ok := rawMap["uri"]; ok {
+ rawMap["uri"] = "http://localhost:8080/some/determinate/url"
+ }
+
+ if _, ok := rawMap["url"]; ok {
+ rawMap["url"] = "http://localhost:8080/some/determinate/url"
+ }
+
+ if _, ok := rawMap["created_at"]; ok {
+ rawMap["created_at"] = "right the hell just now babyee"
+ }
+
+ // Make ID of any mentions determinate.
+ if menchiesRaw, ok := rawMap["mentions"]; ok {
+ menchies, ok := menchiesRaw.([]any)
+ if !ok {
+ suite.FailNow("couldn't coerce menchies")
+ }
+
+ for _, menchieRaw := range menchies {
+ menchie, ok := menchieRaw.(map[string]any)
+ if !ok {
+ suite.FailNow("couldn't coerce menchie")
+ }
+
+ if _, ok := menchie["id"]; ok {
+ menchie["id"] = id.Highest
+ }
+ }
+ }
+
+ // Make fields of any poll determinate.
+ if pollRaw, ok := rawMap["poll"]; ok && pollRaw != nil {
+ poll, ok := pollRaw.(map[string]any)
+ if !ok {
+ suite.FailNow("couldn't coerce poll")
+ }
+
+ if _, ok := poll["id"]; ok {
+ poll["id"] = id.Highest
+ }
+
+ if _, ok := poll["expires_at"]; ok {
+ poll["expires_at"] = "ah like you know whatever dude it's chill"
+ }
+ }
+
+ // Replace account since that's not really
+ // what we care about for these tests.
+ if _, ok := rawMap["account"]; ok {
+ rawMap["account"] = "yeah this is my account, what about it punk"
+ }
+
+ // If status contains an embedded
+ // reblog do the same thing for that.
+ if reblogRaw, ok := rawMap["reblog"]; ok && reblogRaw != nil {
+ reblog, ok := reblogRaw.(map[string]any)
+ if !ok {
+ suite.FailNow("couldn't coerce reblog")
+ }
+ suite.determinateStatus(reblog)
+ }
+}
+
func (suite *StatusStandardTestSuite) SetupSuite() {
suite.testTokens = testrig.NewTestTokens()
suite.testClients = testrig.NewTestClients()
diff --git a/internal/api/client/statuses/statusboost_test.go b/internal/api/client/statuses/statusboost_test.go
index f6f589a5c..8642ba7aa 100644
--- a/internal/api/client/statuses/statusboost_test.go
+++ b/internal/api/client/statuses/statusboost_test.go
@@ -17,9 +17,6 @@
import (
"context"
- "encoding/json"
- "fmt"
- "io/ioutil"
"net/http"
"net/http/httptest"
"strings"
@@ -28,7 +25,7 @@
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
- apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
@@ -38,212 +35,596 @@ type StatusBoostTestSuite struct {
StatusStandardTestSuite
}
-func (suite *StatusBoostTestSuite) TestPostBoost() {
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.DBTokenToToken(t)
-
- targetStatus := suite.testStatuses["admin_account_status_1"]
-
- // setup
+func (suite *StatusBoostTestSuite) postStatusBoost(
+ targetStatusID string,
+ app *gtsmodel.Application,
+ token *gtsmodel.Token,
+ user *gtsmodel.User,
+ account *gtsmodel.Account,
+) (string, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Set(oauth.SessionAuthorizedApplication, app)
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token))
+ ctx.Set(oauth.SessionAuthorizedUser, user)
+ ctx.Set(oauth.SessionAuthorizedAccount, account)
+
+ const pathBase = "http://localhost:8080/api" + statuses.ReblogPath
+ path := strings.ReplaceAll(pathBase, ":"+apiutil.IDKey, targetStatusID)
+ ctx.Request = httptest.NewRequest(http.MethodPost, path, nil)
ctx.Request.Header.Set("accept", "application/json")
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
+ // Populate target status ID.
ctx.Params = gin.Params{
gin.Param{
- Key: statuses.IDKey,
- Value: targetStatus.ID,
+ Key: apiutil.IDKey,
+ Value: targetStatusID,
},
}
+ // Trigger handler.
suite.statusModule.StatusBoostPOSTHandler(ctx)
+ return suite.parseStatusResponse(recorder)
+}
- // check response
- suite.EqualValues(http.StatusOK, recorder.Code)
+func (suite *StatusBoostTestSuite) TestPostBoost() {
+ var (
+ targetStatus = suite.testStatuses["admin_account_status_1"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_1"]
+ user = suite.testUsers["local_account_1"]
+ account = suite.testAccounts["local_account_1"]
+ )
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- suite.NoError(err)
+ out, recorder := suite.postStatusBoost(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
- statusReply := &apimodel.Status{}
- err = json.Unmarshal(b, statusReply)
- suite.NoError(err)
+ // We should have OK from
+ // our call to the function.
+ suite.Equal(http.StatusOK, recorder.Code)
- suite.False(statusReply.Sensitive)
- suite.Equal(apimodel.VisibilityPublic, statusReply.Visibility)
-
- suite.Empty(statusReply.SpoilerText)
- suite.Empty(statusReply.Content)
- suite.Equal("the_mighty_zork", statusReply.Account.Username)
- suite.Len(statusReply.MediaAttachments, 0)
- suite.Len(statusReply.Mentions, 0)
- suite.Len(statusReply.Emojis, 0)
- suite.Len(statusReply.Tags, 0)
-
- suite.NotNil(statusReply.Application)
- suite.Equal("really cool gts application", statusReply.Application.Name)
-
- suite.NotNil(statusReply.Reblog)
- suite.Equal(1, statusReply.Reblog.ReblogsCount)
- suite.Equal(1, statusReply.Reblog.FavouritesCount)
- suite.Equal(targetStatus.Content, statusReply.Reblog.Content)
- suite.Equal(targetStatus.ContentWarning, statusReply.Reblog.SpoilerText)
- suite.Equal(targetStatus.AccountID, statusReply.Reblog.Account.ID)
- suite.Len(statusReply.Reblog.MediaAttachments, 1)
- suite.Len(statusReply.Reblog.Tags, 1)
- suite.Len(statusReply.Reblog.Emojis, 1)
- suite.True(statusReply.Reblogged)
- suite.True(statusReply.Reblog.Reblogged)
- suite.Equal("superseriousbusiness", statusReply.Reblog.Application.Name)
+ // Target status should now
+ // be "reblogged" by us.
+ suite.Equal(`{
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "really cool gts application",
+ "website": "https://reallycool.app"
+ },
+ "bookmarked": true,
+ "card": null,
+ "content": "",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": true,
+ "favourites_count": 0,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": null,
+ "media_attachments": [],
+ "mentions": [],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": {
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "superseriousbusiness",
+ "website": "https://superserious.business"
+ },
+ "bookmarked": true,
+ "card": null,
+ "content": "hello world! #welcome ! first post on the instance :rainbow: !",
+ "created_at": "right the hell just now babyee",
+ "emojis": [
+ {
+ "category": "reactions",
+ "shortcode": "rainbow",
+ "static_url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/static/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
+ "url": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/emoji/original/01F8MH9H8E4VG3KDYJR9EGPXCQ.png",
+ "visible_in_picker": true
+ }
+ ],
+ "favourited": true,
+ "favourites_count": 1,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": "en",
+ "media_attachments": [
+ {
+ "blurhash": "LIIE|gRj00WB-;j[t7j[4nWBj[Rj",
+ "description": "Black and white image of some 50's style text saying: Welcome On Board",
+ "id": "01F8MH6NEM8D7527KZAECTCR76",
+ "meta": {
+ "focus": {
+ "x": 0,
+ "y": 0
+ },
+ "original": {
+ "aspect": 1.9047619,
+ "height": 630,
+ "size": "1200x630",
+ "width": 1200
+ },
+ "small": {
+ "aspect": 1.9104477,
+ "height": 268,
+ "size": "512x268",
+ "width": 512
+ }
+ },
+ "preview_remote_url": null,
+ "preview_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/small/01F8MH6NEM8D7527KZAECTCR76.webp",
+ "remote_url": null,
+ "text_url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg",
+ "type": "image",
+ "url": "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg"
+ }
+ ],
+ "mentions": [],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": null,
+ "reblogged": true,
+ "reblogs_count": 1,
+ "replies_count": 1,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [
+ {
+ "name": "welcome",
+ "url": "http://localhost:8080/tags/welcome"
+ }
+ ],
+ "text": "hello world! #welcome ! first post on the instance :rainbow: !",
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "public"
+ },
+ "reblogged": true,
+ "reblogs_count": 0,
+ "replies_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "public"
+}`, out)
}
func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() {
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.DBTokenToToken(t)
+ var (
+ targetStatus = suite.testStatuses["local_account_1_status_5"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_1"]
+ user = suite.testUsers["local_account_1"]
+ account = suite.testAccounts["local_account_1"]
+ )
- testStatus := suite.testStatuses["local_account_1_status_5"]
- testAccount := suite.testAccounts["local_account_1"]
- testUser := suite.testUsers["local_account_1"]
+ out, recorder := suite.postStatusBoost(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
- recorder := httptest.NewRecorder()
- ctx, _ := testrig.CreateGinTestContext(recorder, nil)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, testUser)
- ctx.Set(oauth.SessionAuthorizedAccount, testAccount)
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", testStatus.ID, 1)), nil)
- ctx.Request.Header.Set("accept", "application/json")
+ // We should have OK from
+ // our call to the function.
+ suite.Equal(http.StatusOK, recorder.Code)
- ctx.Params = gin.Params{
- gin.Param{
- Key: statuses.IDKey,
- Value: testStatus.ID,
- },
- }
-
- suite.statusModule.StatusBoostPOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- suite.NoError(err)
-
- responseStatus := &apimodel.Status{}
- err = json.Unmarshal(b, responseStatus)
- suite.NoError(err)
-
- suite.False(responseStatus.Sensitive)
- suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Visibility)
-
- suite.Empty(responseStatus.SpoilerText)
- suite.Empty(responseStatus.Content)
- suite.Equal("the_mighty_zork", responseStatus.Account.Username)
- suite.Len(responseStatus.MediaAttachments, 0)
- suite.Len(responseStatus.Mentions, 0)
- suite.Len(responseStatus.Emojis, 0)
- suite.Len(responseStatus.Tags, 0)
-
- suite.NotNil(responseStatus.Application)
- suite.Equal("really cool gts application", responseStatus.Application.Name)
-
- suite.NotNil(responseStatus.Reblog)
- suite.Equal(1, responseStatus.Reblog.ReblogsCount)
- suite.Equal(0, responseStatus.Reblog.FavouritesCount)
- suite.Equal(testStatus.Content, responseStatus.Reblog.Content)
- suite.Equal(testStatus.ContentWarning, responseStatus.Reblog.SpoilerText)
- suite.Equal(testStatus.AccountID, responseStatus.Reblog.Account.ID)
- suite.Equal(suite.tc.VisToAPIVis(context.Background(), testStatus.Visibility), responseStatus.Reblog.Visibility)
- suite.Empty(responseStatus.Reblog.MediaAttachments)
- suite.Empty(responseStatus.Reblog.Tags)
- suite.Empty(responseStatus.Reblog.Emojis)
- suite.True(responseStatus.Reblogged)
- suite.True(responseStatus.Reblog.Reblogged)
- suite.Equal("really cool gts application", responseStatus.Reblog.Application.Name)
+ // Target status should now
+ // be "reblogged" by us.
+ suite.Equal(`{
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "really cool gts application",
+ "website": "https://reallycool.app"
+ },
+ "bookmarked": false,
+ "card": null,
+ "content": "",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": false,
+ "favourites_count": 0,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "author",
+ "followers",
+ "mentioned",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "author",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "author",
+ "followers",
+ "mentioned",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": null,
+ "media_attachments": [],
+ "mentions": [],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": {
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "really cool gts application",
+ "website": "https://reallycool.app"
+ },
+ "bookmarked": false,
+ "card": null,
+ "content": "hi!",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": false,
+ "favourites_count": 0,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "author",
+ "followers",
+ "mentioned",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "author",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "author",
+ "followers",
+ "mentioned",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": "en",
+ "media_attachments": [],
+ "mentions": [],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": null,
+ "reblogged": true,
+ "reblogs_count": 1,
+ "replies_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "text": "hi!",
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "private"
+ },
+ "reblogged": true,
+ "reblogs_count": 0,
+ "replies_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "private"
+}`, out)
}
-// try to boost a status that's not boostable / visible to us
+// Try to boost a status that's
+// not boostable / visible to us.
func (suite *StatusBoostTestSuite) TestPostUnboostable() {
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.DBTokenToToken(t)
+ var (
+ targetStatus = suite.testStatuses["local_account_2_status_4"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_1"]
+ user = suite.testUsers["local_account_1"]
+ account = suite.testAccounts["local_account_1"]
+ )
- targetStatus := suite.testStatuses["local_account_2_status_4"]
+ out, recorder := suite.postStatusBoost(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := testrig.CreateGinTestContext(recorder, nil)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
- ctx.Request.Header.Set("accept", "application/json")
-
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
- ctx.Params = gin.Params{
- gin.Param{
- Key: statuses.IDKey,
- Value: targetStatus.ID,
- },
- }
-
- suite.statusModule.StatusBoostPOSTHandler(ctx)
-
- // check response
+ // We should have 403 from
+ // our call to the function.
suite.Equal(http.StatusForbidden, recorder.Code)
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- suite.NoError(err)
- suite.Equal(`{"error":"Forbidden: you do not have permission to boost this status"}`, string(b))
+ // We should have a helpful message.
+ suite.Equal(`{
+ "error": "Forbidden: you do not have permission to boost this status"
+}`, out)
}
-// try to boost a status that's not visible to the user
+// Try to boost a status that's not visible to the user.
func (suite *StatusBoostTestSuite) TestPostNotVisible() {
- // stop local_account_2 following zork
- err := suite.db.DeleteByID(context.Background(), suite.testFollows["local_account_2_local_account_1"].ID, >smodel.Follow{})
- suite.NoError(err)
-
- t := suite.testTokens["local_account_2"]
- oauthToken := oauth.DBTokenToToken(t)
-
- targetStatus := suite.testStatuses["local_account_1_status_3"] // this is a mutual only status and these accounts aren't mutuals
-
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := testrig.CreateGinTestContext(recorder, nil)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_2"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_2"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.ReblogPath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
- ctx.Request.Header.Set("accept", "application/json")
-
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
- ctx.Params = gin.Params{
- gin.Param{
- Key: statuses.IDKey,
- Value: targetStatus.ID,
- },
+ // Stop local_account_2 following zork.
+ err := suite.db.DeleteFollowByID(
+ context.Background(),
+ suite.testFollows["local_account_2_local_account_1"].ID,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
}
- suite.statusModule.StatusBoostPOSTHandler(ctx)
+ var (
+ // This is a mutual only status and
+ // these accounts aren't mutuals anymore.
+ targetStatus = suite.testStatuses["local_account_1_status_3"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_2"]
+ user = suite.testUsers["local_account_2"]
+ account = suite.testAccounts["local_account_2"]
+ )
- // check response
- suite.Equal(http.StatusNotFound, recorder.Code) // we 404 statuses that aren't visible
+ out, recorder := suite.postStatusBoost(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
+
+ // We should have 404 from
+ // our call to the function.
+ suite.Equal(http.StatusNotFound, recorder.Code)
+
+ // We should have a helpful message.
+ suite.Equal(`{
+ "error": "Not Found: target status not found"
+}`, out)
+}
+
+// Boost a status that's pending approval by us.
+func (suite *StatusBoostTestSuite) TestPostBoostImplicitAccept() {
+ var (
+ targetStatus = suite.testStatuses["admin_account_status_5"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_2"]
+ user = suite.testUsers["local_account_2"]
+ account = suite.testAccounts["local_account_2"]
+ )
+
+ out, recorder := suite.postStatusBoost(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
+
+ // We should have OK from
+ // our call to the function.
+ suite.Equal(http.StatusOK, recorder.Code)
+
+ // Target status should now
+ // be "reblogged" by us.
+ suite.Equal(`{
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "really cool gts application",
+ "website": "https://reallycool.app"
+ },
+ "bookmarked": false,
+ "card": null,
+ "content": "",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": false,
+ "favourites_count": 0,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": null,
+ "media_attachments": [],
+ "mentions": [],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": {
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "superseriousbusiness",
+ "website": "https://superserious.business"
+ },
+ "bookmarked": false,
+ "card": null,
+ "content": "Hi @1happyturtle, can I reply?
",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": false,
+ "favourites_count": 0,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
+ "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": null,
+ "media_attachments": [],
+ "mentions": [
+ {
+ "acct": "1happyturtle",
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "url": "http://localhost:8080/@1happyturtle",
+ "username": "1happyturtle"
+ }
+ ],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": null,
+ "reblogged": true,
+ "reblogs_count": 1,
+ "replies_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "text": "Hi @1happyturtle, can I reply?",
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "unlisted"
+ },
+ "reblogged": true,
+ "reblogs_count": 0,
+ "replies_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "unlisted"
+}`, out)
+
+ // Target status should no
+ // longer be pending approval.
+ dbStatus, err := suite.state.DB.GetStatusByID(
+ context.Background(),
+ targetStatus.ID,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.False(*dbStatus.PendingApproval)
+
+ // There should be an Accept
+ // stored for the target status.
+ intReq, err := suite.state.DB.GetInteractionRequestByInteractionURI(
+ context.Background(), targetStatus.URI,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.NotZero(intReq.AcceptedAt)
+ suite.NotEmpty(intReq.URI)
}
func TestStatusBoostTestSuite(t *testing.T) {
diff --git a/internal/api/client/statuses/statuscreate_test.go b/internal/api/client/statuses/statuscreate_test.go
index d32feb6c7..8598b5ef0 100644
--- a/internal/api/client/statuses/statuscreate_test.go
+++ b/internal/api/client/statuses/statuscreate_test.go
@@ -20,18 +20,14 @@
import (
"bytes"
"context"
- "encoding/json"
"fmt"
- "io"
"net/http"
"net/http/httptest"
- "strings"
"testing"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
- "github.com/superseriousbusiness/gotosocial/internal/id"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@@ -81,91 +77,7 @@ func (suite *StatusCreateTestSuite) postStatus(
// Trigger handler.
suite.statusModule.StatusCreatePOSTHandler(ctx)
-
- result := recorder.Result()
- defer result.Body.Close()
-
- data, err := io.ReadAll(result.Body)
- if err != nil {
- suite.FailNow(err.Error())
- }
-
- rawMap := make(map[string]any)
- if err := json.Unmarshal(data, &rawMap); err != nil {
- suite.FailNow(err.Error())
- }
-
- // Replace any fields from the raw map that
- // aren't determinate (date, id, url, etc).
- if _, ok := rawMap["id"]; ok {
- rawMap["id"] = id.Highest
- }
-
- if _, ok := rawMap["uri"]; ok {
- rawMap["uri"] = "http://localhost:8080/some/determinate/url"
- }
-
- if _, ok := rawMap["url"]; ok {
- rawMap["url"] = "http://localhost:8080/some/determinate/url"
- }
-
- if _, ok := rawMap["created_at"]; ok {
- rawMap["created_at"] = "right the hell just now babyee"
- }
-
- // Make ID of any mentions determinate.
- if menchiesRaw, ok := rawMap["mentions"]; ok {
- menchies, ok := menchiesRaw.([]any)
- if !ok {
- suite.FailNow("couldn't coerce menchies")
- }
-
- for _, menchieRaw := range menchies {
- menchie, ok := menchieRaw.(map[string]any)
- if !ok {
- suite.FailNow("couldn't coerce menchie")
- }
-
- if _, ok := menchie["id"]; ok {
- menchie["id"] = id.Highest
- }
- }
- }
-
- // Make fields of any poll determinate.
- if pollRaw, ok := rawMap["poll"]; ok && pollRaw != nil {
- poll, ok := pollRaw.(map[string]any)
- if !ok {
- suite.FailNow("couldn't coerce poll")
- }
-
- if _, ok := poll["id"]; ok {
- poll["id"] = id.Highest
- }
-
- if _, ok := poll["expires_at"]; ok {
- poll["expires_at"] = "ah like you know whatever dude it's chill"
- }
- }
-
- // Replace account since that's not really
- // what we care about for these tests.
- if _, ok := rawMap["account"]; ok {
- rawMap["account"] = "yeah this is my account, what about it punk"
- }
-
- // For readability, don't
- // escape HTML, and indent json.
- out := new(bytes.Buffer)
- enc := json.NewEncoder(out)
- enc.SetEscapeHTML(false)
- enc.SetIndent("", " ")
-
- if err := enc.Encode(&rawMap); err != nil {
- suite.FailNow(err.Error())
- }
-
- return strings.TrimSpace(out.String()), recorder
+ return suite.parseStatusResponse(recorder)
}
// Post a new status with some custom visibility settings
@@ -447,7 +359,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusMessedUpIntPolicy() {
suite.Equal(http.StatusBadRequest, recorder.Code)
// We should have a helpful error
- // message telling us how we screwed up.
+ // message telling us how we screwed up.
suite.Equal(`{
"error": "Bad Request: error converting followers_only.can_reply.always: policyURI public is not feasible for visibility followers_only"
}`, out)
diff --git a/internal/api/client/statuses/statusfave_test.go b/internal/api/client/statuses/statusfave_test.go
index d1042b10e..fdc8741c7 100644
--- a/internal/api/client/statuses/statusfave_test.go
+++ b/internal/api/client/statuses/statusfave_test.go
@@ -18,20 +18,18 @@
package statuses_test
import (
- "encoding/json"
- "fmt"
- "io/ioutil"
+ "context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/api/client/statuses"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
- apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
+ apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
"github.com/superseriousbusiness/gotosocial/internal/oauth"
"github.com/superseriousbusiness/gotosocial/testrig"
)
@@ -40,90 +38,260 @@ type StatusFaveTestSuite struct {
StatusStandardTestSuite
}
-// fave a status
-func (suite *StatusFaveTestSuite) TestPostFave() {
- t := suite.testTokens["local_account_1"]
- oauthToken := oauth.DBTokenToToken(t)
-
- targetStatus := suite.testStatuses["admin_account_status_2"]
-
- // setup
+func (suite *StatusFaveTestSuite) postStatusFave(
+ targetStatusID string,
+ app *gtsmodel.Application,
+ token *gtsmodel.Token,
+ user *gtsmodel.User,
+ account *gtsmodel.Account,
+) (string, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
ctx, _ := testrig.CreateGinTestContext(recorder, nil)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["local_account_1"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["local_account_1"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
+ ctx.Set(oauth.SessionAuthorizedApplication, app)
+ ctx.Set(oauth.SessionAuthorizedToken, oauth.DBTokenToToken(token))
+ ctx.Set(oauth.SessionAuthorizedUser, user)
+ ctx.Set(oauth.SessionAuthorizedAccount, account)
+
+ const pathBase = "http://localhost:8080/api" + statuses.FavouritePath
+ path := strings.ReplaceAll(pathBase, ":"+apiutil.IDKey, targetStatusID)
+ ctx.Request = httptest.NewRequest(http.MethodPost, path, nil)
ctx.Request.Header.Set("accept", "application/json")
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
+ // Populate target status ID.
ctx.Params = gin.Params{
gin.Param{
- Key: statuses.IDKey,
- Value: targetStatus.ID,
+ Key: apiutil.IDKey,
+ Value: targetStatusID,
},
}
+ // Trigger handler.
suite.statusModule.StatusFavePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusOK, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
-
- statusReply := &apimodel.Status{}
- err = json.Unmarshal(b, statusReply)
- assert.NoError(suite.T(), err)
-
- assert.Equal(suite.T(), targetStatus.ContentWarning, statusReply.SpoilerText)
- assert.Equal(suite.T(), targetStatus.Content, statusReply.Content)
- assert.True(suite.T(), statusReply.Sensitive)
- assert.Equal(suite.T(), apimodel.VisibilityPublic, statusReply.Visibility)
- assert.True(suite.T(), statusReply.Favourited)
- assert.Equal(suite.T(), 1, statusReply.FavouritesCount)
+ return suite.parseStatusResponse(recorder)
}
-// try to fave a status that's not faveable
+// Fave a status we haven't faved yet.
+func (suite *StatusFaveTestSuite) TestPostFave() {
+ var (
+ targetStatus = suite.testStatuses["admin_account_status_2"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_1"]
+ user = suite.testUsers["local_account_1"]
+ account = suite.testAccounts["local_account_1"]
+ )
+
+ out, recorder := suite.postStatusFave(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
+
+ // We should have OK from
+ // our call to the function.
+ suite.Equal(http.StatusOK, recorder.Code)
+
+ // Target status should now
+ // be "favourited" by us.
+ suite.Equal(`{
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "superseriousbusiness",
+ "website": "https://superserious.business"
+ },
+ "bookmarked": false,
+ "card": null,
+ "content": "🐕🐕🐕🐕🐕",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": true,
+ "favourites_count": 1,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": null,
+ "in_reply_to_id": null,
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": "en",
+ "media_attachments": [],
+ "mentions": [],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": null,
+ "reblogged": false,
+ "reblogs_count": 0,
+ "replies_count": 0,
+ "sensitive": true,
+ "spoiler_text": "open to see some puppies",
+ "tags": [],
+ "text": "🐕🐕🐕🐕🐕",
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "public"
+}`, out)
+}
+
+// Try to fave a status
+// that's not faveable by us.
func (suite *StatusFaveTestSuite) TestPostUnfaveable() {
- t := suite.testTokens["admin_account"]
- oauthToken := oauth.DBTokenToToken(t)
+ var (
+ targetStatus = suite.testStatuses["local_account_1_status_3"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["admin_account"]
+ user = suite.testUsers["admin_account"]
+ account = suite.testAccounts["admin_account"]
+ )
- targetStatus := suite.testStatuses["local_account_1_status_3"] // this one is unlikeable
+ out, recorder := suite.postStatusFave(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
- // setup
- recorder := httptest.NewRecorder()
- ctx, _ := testrig.CreateGinTestContext(recorder, nil)
- ctx.Set(oauth.SessionAuthorizedApplication, suite.testApplications["application_1"])
- ctx.Set(oauth.SessionAuthorizedToken, oauthToken)
- ctx.Set(oauth.SessionAuthorizedUser, suite.testUsers["admin_account"])
- ctx.Set(oauth.SessionAuthorizedAccount, suite.testAccounts["admin_account"])
- ctx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("http://localhost:8080%s", strings.Replace(statuses.FavouritePath, ":id", targetStatus.ID, 1)), nil) // the endpoint we're hitting
- ctx.Request.Header.Set("accept", "application/json")
+ // We should have 403 from
+ // our call to the function.
+ suite.Equal(http.StatusForbidden, recorder.Code)
- // normally the router would populate these params from the path values,
- // but because we're calling the function directly, we need to set them manually.
- ctx.Params = gin.Params{
- gin.Param{
- Key: statuses.IDKey,
- Value: targetStatus.ID,
- },
+ // We should get a helpful error.
+ suite.Equal(`{
+ "error": "Forbidden: you do not have permission to fave this status"
+}`, out)
+}
+
+// Fave a status that's pending approval by us.
+func (suite *StatusFaveTestSuite) TestPostFaveImplicitAccept() {
+ var (
+ targetStatus = suite.testStatuses["admin_account_status_5"]
+ app = suite.testApplications["application_1"]
+ token = suite.testTokens["local_account_2"]
+ user = suite.testUsers["local_account_2"]
+ account = suite.testAccounts["local_account_2"]
+ )
+
+ out, recorder := suite.postStatusFave(
+ targetStatus.ID,
+ app,
+ token,
+ user,
+ account,
+ )
+
+ // We should have OK from
+ // our call to the function.
+ suite.Equal(http.StatusOK, recorder.Code)
+
+ // Target status should now
+ // be "favourited" by us.
+ suite.Equal(`{
+ "account": "yeah this is my account, what about it punk",
+ "application": {
+ "name": "superseriousbusiness",
+ "website": "https://superserious.business"
+ },
+ "bookmarked": false,
+ "card": null,
+ "content": "Hi @1happyturtle, can I reply?
",
+ "created_at": "right the hell just now babyee",
+ "emojis": [],
+ "favourited": true,
+ "favourites_count": 1,
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
+ "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ },
+ "language": null,
+ "media_attachments": [],
+ "mentions": [
+ {
+ "acct": "1happyturtle",
+ "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZ",
+ "url": "http://localhost:8080/@1happyturtle",
+ "username": "1happyturtle"
+ }
+ ],
+ "muted": false,
+ "pinned": false,
+ "poll": null,
+ "reblog": null,
+ "reblogged": false,
+ "reblogs_count": 0,
+ "replies_count": 0,
+ "sensitive": false,
+ "spoiler_text": "",
+ "tags": [],
+ "text": "Hi @1happyturtle, can I reply?",
+ "uri": "http://localhost:8080/some/determinate/url",
+ "url": "http://localhost:8080/some/determinate/url",
+ "visibility": "unlisted"
+}`, out)
+
+ // Target status should no
+ // longer be pending approval.
+ dbStatus, err := suite.state.DB.GetStatusByID(
+ context.Background(),
+ targetStatus.ID,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
}
+ suite.False(*dbStatus.PendingApproval)
- suite.statusModule.StatusFavePOSTHandler(ctx)
-
- // check response
- suite.EqualValues(http.StatusForbidden, recorder.Code)
-
- result := recorder.Result()
- defer result.Body.Close()
- b, err := ioutil.ReadAll(result.Body)
- assert.NoError(suite.T(), err)
- assert.Equal(suite.T(), `{"error":"Forbidden: you do not have permission to fave this status"}`, string(b))
+ // There should be an Accept
+ // stored for the target status.
+ intReq, err := suite.state.DB.GetInteractionRequestByInteractionURI(
+ context.Background(), targetStatus.URI,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+ suite.NotZero(intReq.AcceptedAt)
+ suite.NotEmpty(intReq.URI)
}
func TestStatusFaveTestSuite(t *testing.T) {
diff --git a/internal/processing/processor.go b/internal/processing/processor.go
index 2ed13d396..ce0f1cfb8 100644
--- a/internal/processing/processor.go
+++ b/internal/processing/processor.go
@@ -223,7 +223,7 @@ func NewProcessor(
processor.tags = tags.New(state, converter)
processor.timeline = timeline.New(state, converter, visFilter)
processor.search = search.New(state, federator, converter, visFilter)
- processor.status = status.New(state, &common, &processor.polls, federator, converter, visFilter, intFilter, parseMentionFunc)
+ processor.status = status.New(state, &common, &processor.polls, &processor.interactionRequests, federator, converter, visFilter, intFilter, parseMentionFunc)
processor.user = user.New(state, converter, oauthServer, emailSender)
// The advanced migrations processor sequences advanced migrations from all other processors.
diff --git a/internal/processing/status/boost.go b/internal/processing/status/boost.go
index 1b6e8bd47..0e09a8e7b 100644
--- a/internal/processing/status/boost.go
+++ b/internal/processing/status/boost.go
@@ -28,6 +28,7 @@
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/messages"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
)
// BoostCreate processes the boost/reblog of target
@@ -138,6 +139,23 @@ func (p *Processor) BoostCreate(
Target: target.Account,
})
+ // If the boost target status replies to a status
+ // that we own, and has a pending interaction
+ // request, use the boost as an implicit accept.
+ implicitlyAccepted, errWithCode := p.implicitlyAccept(ctx,
+ requester, target,
+ )
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ // If we ended up implicitly accepting, mark the
+ // target status as no longer pending approval so
+ // it's serialized properly via the API.
+ if implicitlyAccepted {
+ target.PendingApproval = util.Ptr(false)
+ }
+
return p.c.GetAPIStatus(ctx, requester, boost)
}
diff --git a/internal/processing/status/create.go b/internal/processing/status/create.go
index 1513018ae..184a92680 100644
--- a/internal/processing/status/create.go
+++ b/internal/processing/status/create.go
@@ -164,6 +164,23 @@ func (p *Processor) Create(
}
}
+ // If the new status replies to a status that
+ // replies to us, use our reply as an implicit
+ // accept of any pending interaction.
+ implicitlyAccepted, errWithCode := p.implicitlyAccept(ctx,
+ requester, status,
+ )
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ // If we ended up implicitly accepting, mark the
+ // replied-to status as no longer pending approval
+ // so it's serialized properly via the API.
+ if implicitlyAccepted {
+ status.InReplyTo.PendingApproval = util.Ptr(false)
+ }
+
return p.c.GetAPIStatus(ctx, requester, status)
}
diff --git a/internal/processing/status/fave.go b/internal/processing/status/fave.go
index 497c4d465..defc59af0 100644
--- a/internal/processing/status/fave.go
+++ b/internal/processing/status/fave.go
@@ -31,6 +31,7 @@
"github.com/superseriousbusiness/gotosocial/internal/log"
"github.com/superseriousbusiness/gotosocial/internal/messages"
"github.com/superseriousbusiness/gotosocial/internal/uris"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
)
func (p *Processor) getFaveableStatus(
@@ -138,8 +139,6 @@ func (p *Processor) FaveCreate(
pendingApproval = false
}
- status.PendingApproval = &pendingApproval
-
// Create a new fave, marking it
// as pending approval if necessary.
faveID := id.NewULID()
@@ -157,7 +156,7 @@ func (p *Processor) FaveCreate(
}
if err := p.state.DB.PutStatusFave(ctx, gtsFave); err != nil {
- err = fmt.Errorf("FaveCreate: error putting fave in database: %w", err)
+ err = gtserror.Newf("db error putting fave: %w", err)
return nil, gtserror.NewErrorInternalError(err)
}
@@ -170,6 +169,23 @@ func (p *Processor) FaveCreate(
Target: status.Account,
})
+ // If the fave target status replies to a status
+ // that we own, and has a pending interaction
+ // request, use the fave as an implicit accept.
+ implicitlyAccepted, errWithCode := p.implicitlyAccept(ctx,
+ requester, status,
+ )
+ if errWithCode != nil {
+ return nil, errWithCode
+ }
+
+ // If we ended up implicitly accepting, mark the
+ // target status as no longer pending approval so
+ // it's serialized properly via the API.
+ if implicitlyAccepted {
+ status.PendingApproval = util.Ptr(false)
+ }
+
return p.c.GetAPIStatus(ctx, requester, status)
}
diff --git a/internal/processing/status/status.go b/internal/processing/status/status.go
index 7e614cc31..26dfd0d7a 100644
--- a/internal/processing/status/status.go
+++ b/internal/processing/status/status.go
@@ -23,6 +23,7 @@
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/processing/common"
+ "github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests"
"github.com/superseriousbusiness/gotosocial/internal/processing/polls"
"github.com/superseriousbusiness/gotosocial/internal/state"
"github.com/superseriousbusiness/gotosocial/internal/text"
@@ -42,7 +43,8 @@ type Processor struct {
parseMention gtsmodel.ParseMentionFunc
// other processors
- polls *polls.Processor
+ polls *polls.Processor
+ intReqs *interactionrequests.Processor
}
// New returns a new status processor.
@@ -50,6 +52,7 @@ func New(
state *state.State,
common *common.Processor,
polls *polls.Processor,
+ intReqs *interactionrequests.Processor,
federator *federation.Federator,
converter *typeutils.Converter,
visFilter *visibility.Filter,
@@ -66,5 +69,6 @@ func New(
formatter: text.NewFormatter(state.DB),
parseMention: parseMention,
polls: polls,
+ intReqs: intReqs,
}
}
diff --git a/internal/processing/status/status_test.go b/internal/processing/status/status_test.go
index f0b22b2c1..b3c446d14 100644
--- a/internal/processing/status/status_test.go
+++ b/internal/processing/status/status_test.go
@@ -27,6 +27,7 @@
"github.com/superseriousbusiness/gotosocial/internal/media"
"github.com/superseriousbusiness/gotosocial/internal/processing"
"github.com/superseriousbusiness/gotosocial/internal/processing/common"
+ "github.com/superseriousbusiness/gotosocial/internal/processing/interactionrequests"
"github.com/superseriousbusiness/gotosocial/internal/processing/polls"
"github.com/superseriousbusiness/gotosocial/internal/processing/status"
"github.com/superseriousbusiness/gotosocial/internal/state"
@@ -100,11 +101,13 @@ func (suite *StatusStandardTestSuite) SetupTest() {
common := common.New(&suite.state, suite.mediaManager, suite.typeConverter, suite.federator, visFilter)
polls := polls.New(&common, &suite.state, suite.typeConverter)
+ intReqs := interactionrequests.New(&common, &suite.state, suite.typeConverter)
suite.status = status.New(
&suite.state,
&common,
&polls,
+ &intReqs,
suite.federator,
suite.typeConverter,
visFilter,
diff --git a/internal/processing/status/util.go b/internal/processing/status/util.go
new file mode 100644
index 000000000..99cff7c56
--- /dev/null
+++ b/internal/processing/status/util.go
@@ -0,0 +1,72 @@
+// 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 .
+
+package status
+
+import (
+ "context"
+ "errors"
+
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/gtserror"
+ "github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/util"
+)
+
+func (p *Processor) implicitlyAccept(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ status *gtsmodel.Status,
+) (bool, gtserror.WithCode) {
+ if status.InReplyToAccountID != requester.ID {
+ // Status doesn't reply to us,
+ // we can't accept on behalf
+ // of someone else.
+ return false, nil
+ }
+
+ targetPendingApproval := util.PtrOrValue(status.PendingApproval, false)
+ if !targetPendingApproval {
+ // Status isn't pending approval,
+ // nothing to implicitly accept.
+ return false, nil
+ }
+
+ // Status is pending approval,
+ // check for an interaction request.
+ intReq, err := p.state.DB.GetInteractionRequestByInteractionURI(ctx, status.URI)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ // Something's gone wrong.
+ err := gtserror.Newf("db error getting interaction request for %s: %w", status.URI, err)
+ return false, gtserror.NewErrorInternalError(err)
+ }
+
+ // No interaction request present
+ // for this status. Race condition?
+ if intReq == nil {
+ return false, nil
+ }
+
+ // Accept the interaction.
+ if _, errWithCode := p.intReqs.Accept(ctx,
+ requester, intReq.ID,
+ ); errWithCode != nil {
+ return false, errWithCode
+ }
+
+ return true, nil
+}
diff --git a/internal/typeutils/internaltofrontend.go b/internal/typeutils/internaltofrontend.go
index fe49766fa..f36175eab 100644
--- a/internal/typeutils/internaltofrontend.go
+++ b/internal/typeutils/internaltofrontend.go
@@ -800,26 +800,55 @@ func (c *Converter) TagToAPITag(ctx context.Context, t *gtsmodel.Tag, stubHistor
}, nil
}
-// StatusToAPIStatus converts a gts model status into its api
-// (frontend) representation for serialization on the API.
+// StatusToAPIStatus converts a gts model
+// status into its api (frontend) representation
+// for serialization on the API.
//
// Requesting account can be nil.
//
-// Filter context can be the empty string if these statuses are not being filtered.
+// filterContext can be the empty string
+// if these statuses are not being filtered.
//
-// If there is a matching "hide" filter, the returned status will be nil with a ErrHideStatus error;
-// callers need to handle that case by excluding it from results.
+// If there is a matching "hide" filter, the returned
+// status will be nil with a ErrHideStatus error; callers
+// need to handle that case by excluding it from results.
func (c *Converter) StatusToAPIStatus(
ctx context.Context,
- s *gtsmodel.Status,
+ status *gtsmodel.Status,
requestingAccount *gtsmodel.Account,
filterContext statusfilter.FilterContext,
filters []*gtsmodel.Filter,
mutes *usermute.CompiledUserMuteList,
+) (*apimodel.Status, error) {
+ return c.statusToAPIStatus(
+ ctx,
+ status,
+ requestingAccount,
+ filterContext,
+ filters,
+ mutes,
+ true,
+ true,
+ )
+}
+
+// statusToAPIStatus is the package-internal implementation
+// of StatusToAPIStatus that lets the caller customize whether
+// to placehold unknown attachment types, and/or add a note
+// about the status being pending and requiring approval.
+func (c *Converter) statusToAPIStatus(
+ ctx context.Context,
+ status *gtsmodel.Status,
+ requestingAccount *gtsmodel.Account,
+ filterContext statusfilter.FilterContext,
+ filters []*gtsmodel.Filter,
+ mutes *usermute.CompiledUserMuteList,
+ placeholdAttachments bool,
+ addPendingNote bool,
) (*apimodel.Status, error) {
apiStatus, err := c.statusToFrontend(
ctx,
- s,
+ status,
requestingAccount, // Can be nil.
filterContext, // Can be empty.
filters,
@@ -830,7 +859,7 @@ func (c *Converter) StatusToAPIStatus(
}
// Convert author to API model.
- acct, err := c.AccountToAPIAccountPublic(ctx, s.Account)
+ acct, err := c.AccountToAPIAccountPublic(ctx, status.Account)
if err != nil {
return nil, gtserror.Newf("error converting status acct: %w", err)
}
@@ -839,23 +868,43 @@ func (c *Converter) StatusToAPIStatus(
// Convert author of boosted
// status (if set) to API model.
if apiStatus.Reblog != nil {
- boostAcct, err := c.AccountToAPIAccountPublic(ctx, s.BoostOfAccount)
+ boostAcct, err := c.AccountToAPIAccountPublic(ctx, status.BoostOfAccount)
if err != nil {
return nil, gtserror.Newf("error converting boost acct: %w", err)
}
apiStatus.Reblog.Account = boostAcct
}
- // Normalize status for API by pruning
- // attachments that were not locally
- // stored, replacing them with a helpful
- // message + links to remote.
- var aside string
- aside, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments)
- apiStatus.Content += aside
- if apiStatus.Reblog != nil {
- aside, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments)
- apiStatus.Reblog.Content += aside
+ if placeholdAttachments {
+ // Normalize status for API by pruning attachments
+ // that were not able to be locally stored, and replacing
+ // them with a helpful message + links to remote.
+ var attachNote string
+ attachNote, apiStatus.MediaAttachments = placeholderAttachments(apiStatus.MediaAttachments)
+ apiStatus.Content += attachNote
+
+ // Do the same for the reblogged status.
+ if apiStatus.Reblog != nil {
+ attachNote, apiStatus.Reblog.MediaAttachments = placeholderAttachments(apiStatus.Reblog.MediaAttachments)
+ apiStatus.Reblog.Content += attachNote
+ }
+ }
+
+ if addPendingNote {
+ // If this status is pending approval and
+ // replies to the requester, add a note
+ // about how to approve or reject the reply.
+ pendingApproval := util.PtrOrValue(status.PendingApproval, false)
+ if pendingApproval &&
+ requestingAccount != nil &&
+ requestingAccount.ID == status.InReplyToAccountID {
+ pendingNote, err := c.pendingReplyNote(ctx, status)
+ if err != nil {
+ return nil, gtserror.Newf("error deriving 'pending reply' note: %w", err)
+ }
+
+ apiStatus.Content += pendingNote
+ }
}
return apiStatus, nil
@@ -1972,7 +2021,20 @@ func (c *Converter) ReportToAdminAPIReport(ctx context.Context, r *gtsmodel.Repo
}
}
for _, s := range r.Statuses {
- status, err := c.StatusToAPIStatus(ctx, s, requestingAccount, statusfilter.FilterContextNone, nil, nil)
+ status, err := c.statusToAPIStatus(
+ ctx,
+ s,
+ requestingAccount,
+ statusfilter.FilterContextNone,
+ nil, // No filters.
+ nil, // No mutes.
+ true, // Placehold unknown attachments.
+
+ // Don't add note about
+ // pending, it's not
+ // relevant here.
+ false,
+ )
if err != nil {
return nil, fmt.Errorf("ReportToAdminAPIReport: error converting status with id %s to api status: %w", s.ID, err)
}
@@ -2609,8 +2671,8 @@ func (c *Converter) InteractionReqToAPIInteractionReq(
req.Status,
requestingAcct,
statusfilter.FilterContextNone,
- nil,
- nil,
+ nil, // No filters.
+ nil, // No mutes.
)
if err != nil {
err := gtserror.Newf("error converting interacted status: %w", err)
@@ -2619,13 +2681,20 @@ func (c *Converter) InteractionReqToAPIInteractionReq(
var reply *apimodel.Status
if req.InteractionType == gtsmodel.InteractionReply {
- reply, err = c.StatusToAPIStatus(
+ reply, err = c.statusToAPIStatus(
ctx,
- req.Reply,
+ req.Status,
requestingAcct,
statusfilter.FilterContextNone,
- nil,
- nil,
+ nil, // No filters.
+ nil, // No mutes.
+ true, // Placehold unknown attachments.
+
+ // Don't add note about pending;
+ // requester already knows it's
+ // pending because they're looking
+ // at the request right now.
+ false,
)
if err != nil {
err := gtserror.Newf("error converting reply: %w", err)
diff --git a/internal/typeutils/internaltofrontend_test.go b/internal/typeutils/internaltofrontend_test.go
index a44afe67e..dbb6d6a5d 100644
--- a/internal/typeutils/internaltofrontend_test.go
+++ b/internal/typeutils/internaltofrontend_test.go
@@ -18,6 +18,7 @@
package typeutils_test
import (
+ "bytes"
"context"
"encoding/json"
"testing"
@@ -1708,6 +1709,130 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteraction
}`, string(b))
}
+func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval() {
+ var (
+ testStatus = suite.testStatuses["admin_account_status_5"]
+ requestingAccount = suite.testAccounts["local_account_2"]
+ )
+
+ apiStatus, err := suite.typeconverter.StatusToAPIStatus(
+ context.Background(),
+ testStatus,
+ requestingAccount,
+ statusfilter.FilterContextNone,
+ nil,
+ nil,
+ )
+ if err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ // We want to see the HTML in
+ // the status so don't escape it.
+ out := new(bytes.Buffer)
+ enc := json.NewEncoder(out)
+ enc.SetIndent("", " ")
+ enc.SetEscapeHTML(false)
+ if err := enc.Encode(apiStatus); err != nil {
+ suite.FailNow(err.Error())
+ }
+
+ suite.Equal(`{
+ "id": "01J5QVB9VC76NPPRQ207GG4DRZ",
+ "created_at": "2024-02-20T10:41:37.000Z",
+ "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5",
+ "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF",
+ "sensitive": false,
+ "spoiler_text": "",
+ "visibility": "unlisted",
+ "language": null,
+ "uri": "http://localhost:8080/users/admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
+ "url": "http://localhost:8080/@admin/statuses/01J5QVB9VC76NPPRQ207GG4DRZ",
+ "replies_count": 0,
+ "reblogs_count": 0,
+ "favourites_count": 0,
+ "favourited": false,
+ "reblogged": false,
+ "muted": false,
+ "bookmarked": false,
+ "pinned": false,
+ "content": "Hi @1happyturtle, can I reply?
ℹ️ Note from localhost:8080: This reply is pending your approval. You can quickly accept it by liking, boosting or replying to it. You can also accept or reject it at the following link: http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR.
",
+ "reblog": null,
+ "application": {
+ "name": "superseriousbusiness",
+ "website": "https://superserious.business"
+ },
+ "account": {
+ "id": "01F8MH17FWEB39HZJ76B6VXSKF",
+ "username": "admin",
+ "acct": "admin",
+ "display_name": "",
+ "locked": false,
+ "discoverable": true,
+ "bot": false,
+ "created_at": "2022-05-17T13:10:59.000Z",
+ "note": "",
+ "url": "http://localhost:8080/@admin",
+ "avatar": "",
+ "avatar_static": "",
+ "header": "http://localhost:8080/assets/default_header.webp",
+ "header_static": "http://localhost:8080/assets/default_header.webp",
+ "followers_count": 1,
+ "following_count": 1,
+ "statuses_count": 4,
+ "last_status_at": "2021-10-20T10:41:37.000Z",
+ "emojis": [],
+ "fields": [],
+ "enable_rss": true,
+ "roles": [
+ {
+ "id": "admin",
+ "name": "admin",
+ "color": ""
+ }
+ ]
+ },
+ "media_attachments": [],
+ "mentions": [
+ {
+ "id": "01F8MH5NBDF2MV7CTC4Q5128HF",
+ "username": "1happyturtle",
+ "url": "http://localhost:8080/@1happyturtle",
+ "acct": "1happyturtle"
+ }
+ ],
+ "tags": [],
+ "emojis": [],
+ "card": null,
+ "poll": null,
+ "text": "Hi @1happyturtle, can I reply?",
+ "interaction_policy": {
+ "can_favourite": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reply": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ },
+ "can_reblog": {
+ "always": [
+ "public",
+ "me"
+ ],
+ "with_approval": []
+ }
+ }
+}
+`, out.String())
+}
+
func (suite *InternalToFrontendTestSuite) TestVideoAttachmentToFrontend() {
testAttachment := suite.testAttachments["local_account_1_status_4_attachment_2"]
apiAttachment, err := suite.typeconverter.AttachmentToAPIAttachment(context.Background(), testAttachment)
diff --git a/internal/typeutils/util.go b/internal/typeutils/util.go
index 3a867ba35..1747dbdcd 100644
--- a/internal/typeutils/util.go
+++ b/internal/typeutils/util.go
@@ -19,6 +19,7 @@
import (
"context"
+ "errors"
"fmt"
"math"
"net/url"
@@ -30,6 +31,8 @@
"github.com/k3a/html2text"
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/language"
"github.com/superseriousbusiness/gotosocial/internal/log"
@@ -187,6 +190,47 @@ func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Att
return text.SanitizeToHTML(note.String()), arr
}
+func (c *Converter) pendingReplyNote(
+ ctx context.Context,
+ s *gtsmodel.Status,
+) (string, error) {
+ intReq, err := c.state.DB.GetInteractionRequestByInteractionURI(ctx, s.URI)
+ if err != nil && !errors.Is(err, db.ErrNoEntries) {
+ // Something's gone wrong.
+ err := gtserror.Newf("db error getting interaction request for %s: %w", s.URI, err)
+ return "", err
+ }
+
+ // No interaction request present
+ // for this status. Race condition?
+ if intReq == nil {
+ return "", nil
+ }
+
+ var (
+ proto = config.GetProtocol()
+ host = config.GetHost()
+
+ // Build the settings panel URL at which the user
+ // can view + approve/reject the interaction request.
+ //
+ // Eg., https://example.org/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR
+ settingsURL = proto + "://" + host + "/settings/user/interaction_requests/" + intReq.ID
+ )
+
+ var note strings.Builder
+ note.WriteString(`
`)
+ note.WriteString(`ℹ️ Note from ` + host + `: `)
+ note.WriteString(`This reply is pending your approval. You can quickly accept it by liking, boosting or replying to it. You can also accept or reject it at the following link: `)
+ note.WriteString(``)
+ note.WriteString(settingsURL)
+ note.WriteString(`.`)
+ note.WriteString(`
`)
+
+ return text.SanitizeToHTML(note.String()), nil
+}
+
// ContentToContentLanguage tries to
// extract a content string and language
// tag string from the given intermediary
diff --git a/web/source/settings/views/user/router.tsx b/web/source/settings/views/user/router.tsx
index 86bcf4243..091dd40ae 100644
--- a/web/source/settings/views/user/router.tsx
+++ b/web/source/settings/views/user/router.tsx
@@ -52,10 +52,10 @@ export default function UserRouter() {
+
-
);
@@ -73,13 +73,11 @@ function InteractionRequestsRouter() {
return (
-
-
-
-
-
-
-
+
+
+
+
+
);
From 5bd6ad68e6efe29a6d153ade8f9113973f9e42f5 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Mon, 23 Sep 2024 16:07:13 +0200
Subject: [PATCH 06/12] [bugfix/email] Don't use plainAuth when no smtp
username/password provided (#3332)
* Do not use plainAuth when no user or password. Fixes #3320
* formatting
---------
Co-authored-by: Yonas Yanfa
---
internal/email/sender.go | 23 ++++++++++++++++-------
1 file changed, 16 insertions(+), 7 deletions(-)
diff --git a/internal/email/sender.go b/internal/email/sender.go
index 9db918f8a..2bcc6086f 100644
--- a/internal/email/sender.go
+++ b/internal/email/sender.go
@@ -71,17 +71,26 @@ func NewSender() (Sender, error) {
return nil, err
}
- username := config.GetSMTPUsername()
- password := config.GetSMTPPassword()
- host := config.GetSMTPHost()
- port := config.GetSMTPPort()
- from := config.GetSMTPFrom()
- msgIDHost := config.GetHost()
+ var (
+ username = config.GetSMTPUsername()
+ password = config.GetSMTPPassword()
+ host = config.GetSMTPHost()
+ port = config.GetSMTPPort()
+ from = config.GetSMTPFrom()
+ msgIDHost = config.GetHost()
+ smtpAuth smtp.Auth
+ )
+
+ if username == "" || password == "" {
+ smtpAuth = nil
+ } else {
+ smtpAuth = smtp.PlainAuth("", username, password, host)
+ }
return &sender{
hostAddress: fmt.Sprintf("%s:%d", host, port),
from: from,
- auth: smtp.PlainAuth("", username, password, host),
+ auth: smtpAuth,
msgIDHost: msgIDHost,
template: t,
}, nil
From 459f925391022b722ed22235bfd43665b18dca95 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Mon, 23 Sep 2024 16:41:57 +0200
Subject: [PATCH 07/12] [docs] Update readme alpha -> beta, add more feature
examples (#3333)
---
README.md | 67 +++++++++++++-----
ROADMAP.md | 15 ++--
docs/assets/markdown-post.png | Bin 0 -> 88905 bytes
docs/assets/theme-blurple-dark.png | Bin 0 -> 666065 bytes
docs/assets/theme-blurple-light.png | Bin 0 -> 667871 bytes
docs/assets/theme-brutalist-dark.png | Bin 0 -> 650116 bytes
docs/assets/theme-brutalist-light.png | Bin 0 -> 654321 bytes
docs/assets/theme-ecks-pee.png | Bin 0 -> 875153 bytes
docs/assets/theme-midnight-trip.png | Bin 0 -> 645919 bytes
docs/assets/theme-soft.png | Bin 0 -> 696870 bytes
docs/assets/theme-solarized-dark.png | Bin 0 -> 673246 bytes
docs/assets/theme-solarized-light.png | Bin 0 -> 679086 bytes
docs/assets/theme-sunset.png | Bin 0 -> 668563 bytes
docs/faq.md | 2 +-
.../views/admin/instance/settings.tsx | 4 +-
15 files changed, 64 insertions(+), 24 deletions(-)
create mode 100644 docs/assets/markdown-post.png
create mode 100644 docs/assets/theme-blurple-dark.png
create mode 100644 docs/assets/theme-blurple-light.png
create mode 100644 docs/assets/theme-brutalist-dark.png
create mode 100644 docs/assets/theme-brutalist-light.png
create mode 100644 docs/assets/theme-ecks-pee.png
create mode 100644 docs/assets/theme-midnight-trip.png
create mode 100644 docs/assets/theme-soft.png
create mode 100644 docs/assets/theme-solarized-dark.png
create mode 100644 docs/assets/theme-solarized-light.png
create mode 100644 docs/assets/theme-sunset.png
diff --git a/README.md b/README.md
index 989b7c1fa..a00d2ab68 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ With GoToSocial, you can keep in touch with your friends, post, read, and share
-**GoToSocial is still [ALPHA SOFTWARE](https://en.wikipedia.org/wiki/Software_release_life_cycle#Alpha)**. It is already deployable and useable, and it federates cleanly with many other Fediverse servers (not yet all). However, many things are not yet implemented, and there are plenty of bugs! We foresee entering beta around the beginning of 2024.
+**GoToSocial is still [BETA SOFTWARE](https://en.wikipedia.org/wiki/Software_release_life_cycle#Beta)**. It is already deployable and useable, and it federates cleanly with many other Fediverse servers (not yet all). However, many things are not yet implemented, and there are plenty of bugs! We left alpha stage around September/October 2024, and we intend to exit beta some time around 2026.
Documentation is at [docs.gotosocial.org](https://docs.gotosocial.org). You can skip straight to the API documentation [here](https://docs.gotosocial.org/en/latest/api/swagger/).
@@ -29,7 +29,11 @@ Here's a screenshot of the instance landing page!
- [History and Status](#history-and-status)
- [Features](#features)
- [Mastodon API compatibility](#mastodon-api-compatibility)
- - [Granular post settings](#granular-post-settings)
+ - [Granular post visibility settings](#granular-post-visibility-settings)
+ - [Reply controls](#reply-controls)
+ - [Local-only posting](#local-only-posting)
+ - [RSS feed](#rss-feed)
+ - [Rich text formatting](#rich-text-formatting)
- [Customizability](#customizability)
- [Easy to run](#easy-to-run)
- [Safety + security features](#safety--security-features)
@@ -90,7 +94,9 @@ This project sprang up in February/March 2021 out of a dissatisfaction with the
It began as a solo project, and then picked up steam as more developers became interested and jumped on.
-For a detailed view on what's implemented and what's not, and progress made towards [beta release](https://en.wikipedia.org/wiki/Software_release_life_cycle#Beta), please see [the roadmap document](https://github.com/superseriousbusiness/gotosocial/blob/main/ROADMAP.md).
+We made our first Alpha release in November 2021. We left Alpha and entered Beta in September/October 2024.
+
+For a detailed view on what's implemented and what's not, and progress made towards [stable release](https://en.wikipedia.org/wiki/Software_release_life_cycle#Stable_release), please see [the roadmap document](https://github.com/superseriousbusiness/gotosocial/blob/main/ROADMAP.md).
---
@@ -100,44 +106,73 @@ For a detailed view on what's implemented and what's not, and progress made towa
The Mastodon API has become the de facto standard for client communication with federated servers, so GoToSocial has implemented and extended the API with custom functionality.
-Though most apps that implement the Mastodon API should work, GoToSocial works reliably with beautiful apps like:
+Though most apps that implement the Mastodon API should work, GoToSocial is tested and works reliably with beautiful apps like:
* [Tusky](https://tusky.app/) for Android
* [Semaphore](https://semaphore.social/) in the browser
* [Feditext](https://github.com/feditext/feditext) (beta) on iOS, iPadOS and macOS
-If you've used Mastodon with any of these apps before, you'll find using GoToSocial a breeze.
+If you've used Mastodon with a third-party app before, you'll find using GoToSocial a breeze.
-### Granular post settings
+### Granular post visibility settings
It's important that when you post something, you can choose who sees it.
-GoToSocial offers public/unlisted/friends-only/mutuals-only/and direct posts (slide in DMs! -- with consent).
+GoToSocial offers public, unlisted/unlocked, followers-only, and direct posts (slide in DMs! -- with consent).
-It also allows you to customize how people interact with your posts:
+### Reply controls
-- Local-only posts.
-- Rebloggable/boostable toggle.
-- 'Likeable' toggle.
-- 'Replyable' toggle.
+GoToSocial lets you choose who can reply to your posts, via [interaction policies](https://docs.gotosocial.org/en/latest/user_guide/settings/#default-interaction-policies). You can choose to let anyone reply to your posts, let only your friends reply, and more.
+
+
+
+### Local-only posting
+
+Sometimes you only want to talk to people you share an instance with. GoToSocial supports this via local-only posting, which ensures that your post stays on your instance only. (Local-only posting is currently dependent on client support.)
+
+### RSS feed
+
+GoToSocial lets you opt-in to exposing your profile as an RSS feed, so that people can subscribe to your public feed without missing a post.
+
+### Rich text formatting
+
+With GoToSocial, you can write posts using the popular, easy-to-use Markdown markup language, which lets you produce rich HTML posts with support for blockquotes, syntax-highlighted code blocks, lists, inline links, and more.
+
+
### Customizability
Users can [choose from a variety of fun themes](https://docs.gotosocial.org/en/latest/user_guide/settings/#select-theme) for their profile, or even write their own [custom CSS](https://docs.gotosocial.org/en/latest/user_guide/settings/#custom-css).
-Plenty of [config options](https://github.com/superseriousbusiness/gotosocial/blob/main/example/config.yaml) for admins to play around with too.
+
+Show theme examples
+
+| Theme name | Screenshot |
+| --------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| Blurple dark |
|
+| Blurple light |
|
+| Brutalist light |
|
+| Brutalist dark |
|
+| Ecks pee |
|
+| Midnight trip |
|
+| Soft |
|
+| Solarized dark |
|
+| Solarized light |
|
+| Sunset |
|
+
+
### Easy to run
No external dependencies apart from a database (or just use SQLite!). Simply download the binary + assets (or Docker container), and run.
-GoToSocial uses only about 150-250MiB of RAM, so it plays nice with single-board computers, old laptops and tiny $5/month VPSes.
+GoToSocial uses only about 200-300MiB of RAM, and requires very little CPU power, so it plays nice with single-board computers, old laptops and tiny $5/month VPSes.
### Safety + security features
- Built-in, automatic support for secure HTTPS with [Let's Encrypt](https://letsencrypt.org/).
- Strict privacy enforcement for posts and strict blocking logic.
-- Import and export allow lists and deny lists. Subscribe to community-created block lists (think Ad blocker, but for federation!).
+- Import and export allow lists and deny lists. Subscribe to community-created block lists (think Ad blocker, but for federation!) (feature still in progress).
- HTTP signature authentication: GoToSocial requires [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-12) when sending and receiving messages, to ensure that your messages can't be tampered with and your identity can't be forged.
### Various federation modes
@@ -166,7 +201,7 @@ On top of this API, web developers are encouraged to build any front-end impleme
## Known Issues
-Since GoToSocial is still in alpha, there are plenty of bugs. We use [GitHub issues](https://github.com/superseriousbusiness/gotosocial/issues?q=is%3Aissue+is%3Aopen+label%3Abug) to track these.
+Since GoToSocial is still in beta, there are plenty of bugs. We use [GitHub issues](https://github.com/superseriousbusiness/gotosocial/issues?q=is%3Aissue+is%3Aopen+label%3Abug) to track these.
Since every ActivityPub server implementation has a slightly different interpretation of the protocol, some servers don't quite federate properly with GoToSocial yet. We're tracking these issues [in this project](https://github.com/superseriousbusiness/gotosocial/projects/4). Eventually, we want to make sure that any implementation that can federate nicely with Mastodon should also be able to federate with GoToSocial.
diff --git a/ROADMAP.md b/ROADMAP.md
index f7af8ce92..a4894f4fe 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,10 +1,10 @@
# Roadmap to Beta
-This document contains the roadmap for GoToSocial to be considered eligible for its first [beta release](https://en.wikipedia.org/wiki/Software_release_life_cycle#Beta).
+This document contains the roadmap for GoToSocial to be considered eligible for its first proper [stable release](https://en.wikipedia.org/wiki/Software_release_life_cycle#Stable_release).
All the info contained in this document is best-guess only. It's useful to have a rough timeline we can direct people to, but things will undoubtedly change along the way; don't hold us to anything in this doc!
-Thank you to [NLnet](https://nlnet.nl) for helping to fund the alpha phase of GoToSocial development and get us moving towards beta!
+Thank you to [NLnet](https://nlnet.nl) for helping to fund the alpha and beta phases of GoToSocial development!
Big thank you to all of our [Open Collective](https://opencollective.com/gotosocial) and [Liberapay](https://liberapay.com/gotosocial) contributors, who've helped us keep the lights on! 💕
@@ -17,6 +17,7 @@ Big thank you to all of our [Open Collective](https://opencollective.com/gotosoc
- [Early 2024](#early-2024)
- [BETA milestone](#beta-milestone)
- [Remainder 2024 - early 2025](#remainder-2024---early-2025)
+ - [On the way out of BETA to STABLE RELEASE](#on-the-way-out-of-beta-to-stable-release)
- [Wishlist](#wishlist)
## Beta Aims
@@ -61,7 +62,7 @@ What follows is a rough timeline of features that will be implemented on the roa
### BETA milestone
-Completion of all above features indicates that we are now in the BETA phase of GoToSocial. We foresee this happening around Feb/March 2024.
+Completion of all above features indicates that we are now in the BETA phase of GoToSocial. We foresee this happening around Feb/March 2024. EDIT: It ended up happening in September/October 2024, whoops!
### Remainder 2024 - early 2025
@@ -69,9 +70,9 @@ These are provided in no specific order.
- [x] **Filters v2** -- implement v2 of the filters API.
- [x] **Mute accounts** -- mute accounts to prevent their posts showing up in your home timeline (optional: for limited period of time).
-- [ ] **Non-replyable posts** -- design a non-replyable post path for GoToSocial based on https://github.com/mastodon/mastodon/issues/14762#issuecomment-1196889788; allow users to create non-replyable posts.
+- [x] **Non-replyable posts** -- design a non-replyable post path for GoToSocial based on https://github.com/mastodon/mastodon/issues/14762#issuecomment-1196889788; allow users to create non-replyable posts.
- [ ] **Block + allow list subscriptions** -- allow instance admins to subscribe their instance to plaintext domain block/allow lists (much of the work for this is already in place).
-- [ ] **Direct conversation view** -- allow users to easily page through all direct-message conversations they're a part of.
+- [x] **Direct conversation view** -- allow users to easily page through all direct-message conversations they're a part of.
- [ ] **Oauth token management** -- create / view / invalidate OAuth tokens via the settings panel.
- [ ] **Status EDIT support** -- edit statuses that you've created, without having to delete + redraft. Federate edits out properly.
- [ ] **Fediverse relay support** -- publish posts to relays, pull posts from relays.
@@ -80,6 +81,10 @@ These are provided in no specific order.
More tbd!
+### On the way out of BETA to STABLE RELEASE
+
+Tbd.
+
## Wishlist
These cool things will be implemented if time allows (because we really want them):
diff --git a/docs/assets/markdown-post.png b/docs/assets/markdown-post.png
new file mode 100644
index 0000000000000000000000000000000000000000..750f36e49e55d0ab5b4f70d03687fa60d1d22cee
GIT binary patch
literal 88905
zcmb5VWl&tt6E;eM1%g{}hXq0)xVyUtcPF^JySpsz?!je)ySoQ>7I(S)lK*?_-m3fM
zZk?*#b82RKrcX~l-Ou!f$;*l%!sEh2KtLc$hzl!1Kzz)EfPkX^1oQqx1y;rV{R_%L
zP(u0B`^WQ>QRw?Uj-!a0qmqq@ql>=1F@&kLjg|2?2Sa;fV`~R98%HoCi1)n_#ea>2
z?2Yvu&26lSmCdb;A*}R?8JUP3jBSY-zcVruGq7@gXX0dHALjK7hJYZ3kPsG7cFj0h
zbMeG*>K?e57`5DxL6#8r3!MBYj%n_0AVy0?b2Owr?!INn0j|lsu`FI+nyAmnGx%_Q
zq9!jOpj`F~f;#Yeopq^`>FT*QUGNi%5o8JIuER4cc69vJ#Kh&c4Mc;8$15p@0wwU@
zBMxaBmx4LX-`n$JQV}D1J~GE(|3bRU!oO|0ZGNBS%EjyX^q3(F
z7Jo|n+wGHJh&&xakWeX)#~DxHEwQ7dwlmeg5kEZ@uS}+8^6coP2>layk4Z(lx_%07
zi`4mIb?~o$x8IagW>t&g*|XS`jncn%p-|d3pR-Bq4^l|2;Quv?TS#UyMrDWoJMX~t
z!U)W;e`|REeQbau{r5T4CNWCx|He3QB6a*2qwcFH5Xh^OgSS3+Mo`CcpOiX3TPzl3;IaV6s
zbBw<$W{2P;7AbODBR!UuvUl*z3#op}kR3PoF(UuYh{nZtK)0VRr?cnED>}D1R!7wi
zbS(Ky@$a2RU>!BP`eJ4--9iAaFD?ydq3mU!457lE%yu(w#k?bdzu~;vjaDE-TG{_8
z9i+9HLGAMRV44m*=psKZA(Q3%%|pODi&|_L?^HzTe%n!tG4ZQyygTRtX5BYx70MLgHDn+4a+XeDvJ*xDZ;phMB5&pa8er|E}?tj^Fb
zNuxUgt{vA`+p7(?$3iz+wR1|F&P(RERIc=mn1a@Nr~HcV!Myvu@;x10#>G*?M~uurbetScZRbDkn>xHRMC
zJe~IOt`8x~jCdvKW{{)3-Hg|Vi=;KBdr>F4wh0^7_A$FkufL$HpkG7Y2uDd74Kju^
z;27K)T^dH}{GTm>Fx-tE9Zl2%AgCmFl!B^`#p46l&0W3reWVoWMG!;;;y9x#ioDtVyTx;QSFz7DNU*6H_~_O7%Yad^E{XWPIs|A
zWO97pI_~LexA7hRfZd3>-4=R2xl6
zRoDOOU8p}DMoW8`Bzxx7BuRb3UD)VI#U=PXn##x1M*!%}TllsKUI6v>d&!oUP1^L2
zt@4!fDei`>+HK=u^b-w^y0EG>z2mo$-Cva2J0GA%A}8;E
zJ``DhKrf*H+mGmeQDk*TqF;OD!tWYu|Gw|dYcu(QY6|YW*mK)
z(s=Yjn$Ws$LrcghDru(^lHH=;$w(t(^?KT*{q!l}km=^F89Ylx>jzQSC4E_g-g4^<
z`OU{Ii=~oh=?prVer?5$-=4m$VM|L)u-*czMp5F@JBdImr%vNA
zpE1AMn8D1i5N*|mYZHAHF=n#RQ@-asZay9^F`tt!2cExV(3o;0!KFM0DT(c%eDl*-
zlq5Ek8(8#medZYWkYg07ryP&@xd%JVd~Zh4WuL6?K-}a&0>Cs3<2P)hewpCXfStx4ICfWS1MM5RZFs4@B75>X+Iwh)?rgYOG1~|EzjF^af=3GjLyE@t>0)4WGXz5ah}1LwKUan
zIYy=@EHSn#aAD+xZsd?UOVZZGZZ{O^{d=Z1e@bnR?C{;7?