mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-02-04 23:27:09 +01:00
Merge branch 'main' into interaction_policy_updates
This commit is contained in:
commit
5e80c8a01d
53 changed files with 1443 additions and 586 deletions
|
@ -69,6 +69,36 @@
|
||||||
"go.uber.org/automaxprocs/maxprocs"
|
"go.uber.org/automaxprocs/maxprocs"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Maintenance starts and creates a GoToSocial server
|
||||||
|
// in maintenance mode (returns 503 for most requests).
|
||||||
|
var Maintenance action.GTSAction = func(ctx context.Context) error {
|
||||||
|
route, err := router.New(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating maintenance router: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route maintenance handlers.
|
||||||
|
maintenance := web.NewMaintenance()
|
||||||
|
maintenance.Route(route)
|
||||||
|
|
||||||
|
// Start the maintenance router.
|
||||||
|
if err := route.Start(); err != nil {
|
||||||
|
return fmt.Errorf("error starting maintenance router: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catch shutdown signals from the OS.
|
||||||
|
sigs := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
sig := <-sigs // block until signal received
|
||||||
|
log.Infof(ctx, "received signal %s, shutting down", sig)
|
||||||
|
|
||||||
|
if err := route.Stop(); err != nil {
|
||||||
|
log.Errorf(ctx, "error stopping router: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Start creates and starts a gotosocial server
|
// Start creates and starts a gotosocial server
|
||||||
var Start action.GTSAction = func(ctx context.Context) error {
|
var Start action.GTSAction = func(ctx context.Context) error {
|
||||||
// Set GOMAXPROCS / GOMEMLIMIT
|
// Set GOMAXPROCS / GOMEMLIMIT
|
||||||
|
@ -148,6 +178,23 @@
|
||||||
log.Info(ctx, "done! exiting...")
|
log.Info(ctx, "done! exiting...")
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// Create maintenance router.
|
||||||
|
var err error
|
||||||
|
route, err = router.New(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error creating maintenance router: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Route maintenance handlers.
|
||||||
|
maintenance := web.NewMaintenance()
|
||||||
|
maintenance.Route(route)
|
||||||
|
|
||||||
|
// Start the maintenance router to handle reqs
|
||||||
|
// while the instance is starting up / migrating.
|
||||||
|
if err := route.Start(); err != nil {
|
||||||
|
return fmt.Errorf("error starting maintenance router: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize tracing (noop if not enabled).
|
// Initialize tracing (noop if not enabled).
|
||||||
if err := tracing.Initialize(); err != nil {
|
if err := tracing.Initialize(); err != nil {
|
||||||
return fmt.Errorf("error initializing tracing: %w", err)
|
return fmt.Errorf("error initializing tracing: %w", err)
|
||||||
|
@ -359,9 +406,15 @@ func(context.Context, time.Time) {
|
||||||
HTTP router initialization
|
HTTP router initialization
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// Close down the maintenance router.
|
||||||
|
if err := route.Stop(); err != nil {
|
||||||
|
return fmt.Errorf("error stopping maintenance router: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instantiate the main router.
|
||||||
route, err = router.New(ctx)
|
route, err = router.New(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error creating router: %s", err)
|
return fmt.Errorf("error creating main router: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start preparing middleware stack.
|
// Start preparing middleware stack.
|
||||||
|
|
|
@ -41,5 +41,19 @@ func serverCommands() *cobra.Command {
|
||||||
}
|
}
|
||||||
config.AddServerFlags(serverStartCmd)
|
config.AddServerFlags(serverStartCmd)
|
||||||
serverCmd.AddCommand(serverStartCmd)
|
serverCmd.AddCommand(serverStartCmd)
|
||||||
|
|
||||||
|
serverMaintenanceCmd := &cobra.Command{
|
||||||
|
Use: "maintenance",
|
||||||
|
Short: "start the gotosocial server in maintenance mode (returns 503 for almost all requests)",
|
||||||
|
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return preRun(preRunArgs{cmd: cmd})
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return run(cmd.Context(), server.Maintenance)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
config.AddServerFlags(serverMaintenanceCmd)
|
||||||
|
serverCmd.AddCommand(serverMaintenanceCmd)
|
||||||
|
|
||||||
return serverCmd
|
return serverCmd
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,6 @@ We need to register a new application, which we can then use to request an OAuth
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl \
|
curl \
|
||||||
-X POST \
|
|
||||||
-H 'Content-Type:application/json' \
|
-H 'Content-Type:application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"client_name": "your_app_name",
|
"client_name": "your_app_name",
|
||||||
|
@ -89,7 +88,6 @@ You can do this with another `POST` request that looks like the following:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl \
|
curl \
|
||||||
-X POST \
|
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
|
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
|
||||||
|
|
|
@ -138,4 +138,15 @@ instance-subscriptions-process-from: "23:00"
|
||||||
# Examples: ["24h", "72h", "12h"]
|
# Examples: ["24h", "72h", "12h"]
|
||||||
# Default: "24h" (once per day).
|
# Default: "24h" (once per day).
|
||||||
instance-subscriptions-process-every: "24h"
|
instance-subscriptions-process-every: "24h"
|
||||||
|
|
||||||
|
# Bool. Set this to true to randomize stats served at
|
||||||
|
# the /api/v1|v2/instance and /nodeinfo/2.0 endpoints.
|
||||||
|
#
|
||||||
|
# This can be useful when you don't want bots to obtain
|
||||||
|
# reliable information about the amount of users and
|
||||||
|
# statuses on your instance.
|
||||||
|
#
|
||||||
|
# Options: [true, false]
|
||||||
|
# Default: false
|
||||||
|
instance-stats-randomize: false
|
||||||
```
|
```
|
||||||
|
|
|
@ -8,7 +8,6 @@
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl \
|
curl \
|
||||||
-X POST \
|
|
||||||
-H 'Content-Type:application/json' \
|
-H 'Content-Type:application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"client_name": "your_app_name",
|
"client_name": "your_app_name",
|
||||||
|
@ -89,7 +88,6 @@ YOUR_AUTHORIZATION_TOKEN
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl \
|
curl \
|
||||||
-X POST \
|
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{
|
-d '{
|
||||||
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
|
"redirect_uri": "urn:ietf:wg:oauth:2.0:oob",
|
||||||
|
|
|
@ -425,6 +425,17 @@ instance-subscriptions-process-from: "23:00"
|
||||||
# Default: "24h" (once per day).
|
# Default: "24h" (once per day).
|
||||||
instance-subscriptions-process-every: "24h"
|
instance-subscriptions-process-every: "24h"
|
||||||
|
|
||||||
|
# Bool. Set this to true to randomize stats served at
|
||||||
|
# the /api/v1|v2/instance and /nodeinfo/2.0 endpoints.
|
||||||
|
#
|
||||||
|
# This can be useful when you don't want bots to obtain
|
||||||
|
# reliable information about the amount of users and
|
||||||
|
# statuses on your instance.
|
||||||
|
#
|
||||||
|
# Options: [true, false]
|
||||||
|
# Default: false
|
||||||
|
instance-stats-randomize: false
|
||||||
|
|
||||||
###########################
|
###########################
|
||||||
##### ACCOUNTS CONFIG #####
|
##### ACCOUNTS CONFIG #####
|
||||||
###########################
|
###########################
|
||||||
|
|
|
@ -21,7 +21,9 @@
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/util"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
@ -58,6 +60,12 @@ func (m *Module) InstanceInformationGETHandlerV1(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.GetInstanceStatsRandomize() {
|
||||||
|
// Replace actual stats with cached randomized ones.
|
||||||
|
instance.Stats["user_count"] = util.Ptr(int(instance.RandomStats.TotalUsers))
|
||||||
|
instance.Stats["status_count"] = util.Ptr(int(instance.RandomStats.Statuses))
|
||||||
|
}
|
||||||
|
|
||||||
apiutil.JSON(c, http.StatusOK, instance)
|
apiutil.JSON(c, http.StatusOK, instance)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -93,5 +101,10 @@ func (m *Module) InstanceInformationGETHandlerV2(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.GetInstanceStatsRandomize() {
|
||||||
|
// Replace actual stats with cached randomized ones.
|
||||||
|
instance.Usage.Users.ActiveMonth = int(instance.RandomStats.MonthlyActiveUsers)
|
||||||
|
}
|
||||||
|
|
||||||
apiutil.JSON(c, http.StatusOK, instance)
|
apiutil.JSON(c, http.StatusOK, instance)
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,10 @@
|
||||||
|
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import "mime/multipart"
|
import (
|
||||||
|
"mime/multipart"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// InstanceSettingsUpdateRequest models an instance update request.
|
// InstanceSettingsUpdateRequest models an instance update request.
|
||||||
//
|
//
|
||||||
|
@ -148,3 +151,11 @@ type InstanceConfigurationEmojis struct {
|
||||||
// example: 51200
|
// example: 51200
|
||||||
EmojiSizeLimit int `json:"emoji_size_limit"`
|
EmojiSizeLimit int `json:"emoji_size_limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// swagger:ignore
|
||||||
|
type RandomStats struct {
|
||||||
|
Statuses int64
|
||||||
|
TotalUsers int64
|
||||||
|
MonthlyActiveUsers int64
|
||||||
|
Generated time.Time
|
||||||
|
}
|
||||||
|
|
|
@ -110,6 +110,13 @@ type InstanceV1 struct {
|
||||||
Terms string `json:"terms,omitempty"`
|
Terms string `json:"terms,omitempty"`
|
||||||
// Raw (unparsed) version of terms.
|
// Raw (unparsed) version of terms.
|
||||||
TermsRaw string `json:"terms_text,omitempty"`
|
TermsRaw string `json:"terms_text,omitempty"`
|
||||||
|
|
||||||
|
// Random stats generated for the instance.
|
||||||
|
// Only used if `instance-stats-randomize` is true.
|
||||||
|
// Not serialized to the frontend.
|
||||||
|
//
|
||||||
|
// swagger:ignore
|
||||||
|
RandomStats `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstanceV1URLs models instance-relevant URLs for client application consumption.
|
// InstanceV1URLs models instance-relevant URLs for client application consumption.
|
||||||
|
|
|
@ -74,6 +74,13 @@ type InstanceV2 struct {
|
||||||
Terms string `json:"terms,omitempty"`
|
Terms string `json:"terms,omitempty"`
|
||||||
// Raw (unparsed) version of terms.
|
// Raw (unparsed) version of terms.
|
||||||
TermsText string `json:"terms_text,omitempty"`
|
TermsText string `json:"terms_text,omitempty"`
|
||||||
|
|
||||||
|
// Random stats generated for the instance.
|
||||||
|
// Only used if `instance-stats-randomize` is true.
|
||||||
|
// Not serialized to the frontend.
|
||||||
|
//
|
||||||
|
// swagger:ignore
|
||||||
|
RandomStats `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage data for this instance.
|
// Usage data for this instance.
|
||||||
|
|
|
@ -104,13 +104,14 @@ func (e *Emoji) UncacheRemote(ctx context.Context, olderThan time.Time) (int, er
|
||||||
return total, gtserror.Newf("error getting remote emoji: %w", err)
|
return total, gtserror.Newf("error getting remote emoji: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no emojis / same group is returned, we reached the end.
|
// If no emojis / same group is
|
||||||
|
// returned, we reached the end.
|
||||||
if len(emojis) == 0 ||
|
if len(emojis) == 0 ||
|
||||||
olderThan.Equal(emojis[len(emojis)-1].CreatedAt) {
|
olderThan.Equal(emojis[len(emojis)-1].CreatedAt) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use last created-at as the next 'olderThan' value.
|
// Use last createdAt as next 'olderThan' value.
|
||||||
olderThan = emojis[len(emojis)-1].CreatedAt
|
olderThan = emojis[len(emojis)-1].CreatedAt
|
||||||
|
|
||||||
for _, emoji := range emojis {
|
for _, emoji := range emojis {
|
||||||
|
|
|
@ -90,6 +90,7 @@ type Configuration struct {
|
||||||
InstanceLanguages language.Languages `name:"instance-languages" usage:"BCP47 language tags for the instance. Used to indicate the preferred languages of instance residents (in order from most-preferred to least-preferred)."`
|
InstanceLanguages language.Languages `name:"instance-languages" usage:"BCP47 language tags for the instance. Used to indicate the preferred languages of instance residents (in order from most-preferred to least-preferred)."`
|
||||||
InstanceSubscriptionsProcessFrom string `name:"instance-subscriptions-process-from" usage:"Time of day from which to start running instance subscriptions processing jobs. Should be in the format 'hh:mm:ss', eg., '15:04:05'."`
|
InstanceSubscriptionsProcessFrom string `name:"instance-subscriptions-process-from" usage:"Time of day from which to start running instance subscriptions processing jobs. Should be in the format 'hh:mm:ss', eg., '15:04:05'."`
|
||||||
InstanceSubscriptionsProcessEvery time.Duration `name:"instance-subscriptions-process-every" usage:"Period to elapse between instance subscriptions processing jobs, starting from instance-subscriptions-process-from."`
|
InstanceSubscriptionsProcessEvery time.Duration `name:"instance-subscriptions-process-every" usage:"Period to elapse between instance subscriptions processing jobs, starting from instance-subscriptions-process-from."`
|
||||||
|
InstanceStatsRandomize bool `name:"instance-stats-randomize" usage:"Set to true to randomize the stats served at api/v1/instance and api/v2/instance endpoints. Home page stats remain unchanged."`
|
||||||
|
|
||||||
AccountsRegistrationOpen bool `name:"accounts-registration-open" usage:"Allow anyone to submit an account signup request. If false, server will be invite-only."`
|
AccountsRegistrationOpen bool `name:"accounts-registration-open" usage:"Allow anyone to submit an account signup request. If false, server will be invite-only."`
|
||||||
AccountsReasonRequired bool `name:"accounts-reason-required" usage:"Do new account signups require a reason to be submitted on registration?"`
|
AccountsReasonRequired bool `name:"accounts-reason-required" usage:"Do new account signups require a reason to be submitted on registration?"`
|
||||||
|
|
|
@ -92,6 +92,7 @@ func (s *ConfigState) AddServerFlags(cmd *cobra.Command) {
|
||||||
cmd.Flags().StringSlice(InstanceLanguagesFlag(), cfg.InstanceLanguages.TagStrs(), fieldtag("InstanceLanguages", "usage"))
|
cmd.Flags().StringSlice(InstanceLanguagesFlag(), cfg.InstanceLanguages.TagStrs(), fieldtag("InstanceLanguages", "usage"))
|
||||||
cmd.Flags().String(InstanceSubscriptionsProcessFromFlag(), cfg.InstanceSubscriptionsProcessFrom, fieldtag("InstanceSubscriptionsProcessFrom", "usage"))
|
cmd.Flags().String(InstanceSubscriptionsProcessFromFlag(), cfg.InstanceSubscriptionsProcessFrom, fieldtag("InstanceSubscriptionsProcessFrom", "usage"))
|
||||||
cmd.Flags().Duration(InstanceSubscriptionsProcessEveryFlag(), cfg.InstanceSubscriptionsProcessEvery, fieldtag("InstanceSubscriptionsProcessEvery", "usage"))
|
cmd.Flags().Duration(InstanceSubscriptionsProcessEveryFlag(), cfg.InstanceSubscriptionsProcessEvery, fieldtag("InstanceSubscriptionsProcessEvery", "usage"))
|
||||||
|
cmd.Flags().Bool(InstanceStatsRandomizeFlag(), cfg.InstanceStatsRandomize, fieldtag("InstanceStatsRandomize", "usage"))
|
||||||
|
|
||||||
// Accounts
|
// Accounts
|
||||||
cmd.Flags().Bool(AccountsRegistrationOpenFlag(), cfg.AccountsRegistrationOpen, fieldtag("AccountsRegistrationOpen", "usage"))
|
cmd.Flags().Bool(AccountsRegistrationOpenFlag(), cfg.AccountsRegistrationOpen, fieldtag("AccountsRegistrationOpen", "usage"))
|
||||||
|
|
|
@ -1057,6 +1057,31 @@ func SetInstanceSubscriptionsProcessEvery(v time.Duration) {
|
||||||
global.SetInstanceSubscriptionsProcessEvery(v)
|
global.SetInstanceSubscriptionsProcessEvery(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetInstanceStatsRandomize safely fetches the Configuration value for state's 'InstanceStatsRandomize' field
|
||||||
|
func (st *ConfigState) GetInstanceStatsRandomize() (v bool) {
|
||||||
|
st.mutex.RLock()
|
||||||
|
v = st.config.InstanceStatsRandomize
|
||||||
|
st.mutex.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetInstanceStatsRandomize safely sets the Configuration value for state's 'InstanceStatsRandomize' field
|
||||||
|
func (st *ConfigState) SetInstanceStatsRandomize(v bool) {
|
||||||
|
st.mutex.Lock()
|
||||||
|
defer st.mutex.Unlock()
|
||||||
|
st.config.InstanceStatsRandomize = v
|
||||||
|
st.reloadToViper()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceStatsRandomizeFlag returns the flag name for the 'InstanceStatsRandomize' field
|
||||||
|
func InstanceStatsRandomizeFlag() string { return "instance-stats-randomize" }
|
||||||
|
|
||||||
|
// GetInstanceStatsRandomize safely fetches the value for global configuration 'InstanceStatsRandomize' field
|
||||||
|
func GetInstanceStatsRandomize() bool { return global.GetInstanceStatsRandomize() }
|
||||||
|
|
||||||
|
// SetInstanceStatsRandomize safely sets the value for global configuration 'InstanceStatsRandomize' field
|
||||||
|
func SetInstanceStatsRandomize(v bool) { global.SetInstanceStatsRandomize(v) }
|
||||||
|
|
||||||
// GetAccountsRegistrationOpen safely fetches the Configuration value for state's 'AccountsRegistrationOpen' field
|
// GetAccountsRegistrationOpen safely fetches the Configuration value for state's 'AccountsRegistrationOpen' field
|
||||||
func (st *ConfigState) GetAccountsRegistrationOpen() (v bool) {
|
func (st *ConfigState) GetAccountsRegistrationOpen() (v bool) {
|
||||||
st.mutex.RLock()
|
st.mutex.RLock()
|
||||||
|
@ -2699,7 +2724,7 @@ func (st *ConfigState) SetAdvancedRateLimitExceptionsParsed(v []netip.Prefix) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdvancedRateLimitExceptionsParsedFlag returns the flag name for the 'AdvancedRateLimitExceptionsParsed' field
|
// AdvancedRateLimitExceptionsParsedFlag returns the flag name for the 'AdvancedRateLimitExceptionsParsed' field
|
||||||
func AdvancedRateLimitExceptionsParsedFlag() string { return "" }
|
func AdvancedRateLimitExceptionsParsedFlag() string { return "advanced-rate-limit-exceptions-parsed" }
|
||||||
|
|
||||||
// GetAdvancedRateLimitExceptionsParsed safely fetches the value for global configuration 'AdvancedRateLimitExceptionsParsed' field
|
// GetAdvancedRateLimitExceptionsParsed safely fetches the value for global configuration 'AdvancedRateLimitExceptionsParsed' field
|
||||||
func GetAdvancedRateLimitExceptionsParsed() []netip.Prefix {
|
func GetAdvancedRateLimitExceptionsParsed() []netip.Prefix {
|
||||||
|
|
|
@ -404,7 +404,8 @@ func (f *Federator) callForPubKey(
|
||||||
pubKeyID *url.URL,
|
pubKeyID *url.URL,
|
||||||
) ([]byte, gtserror.WithCode) {
|
) ([]byte, gtserror.WithCode) {
|
||||||
// Use a transport to dereference the remote.
|
// Use a transport to dereference the remote.
|
||||||
trans, err := f.transportController.NewTransportForUsername(
|
trans, err := f.transport.NewTransportForUsername(
|
||||||
|
|
||||||
// We're on a hot path: don't retry if req fails.
|
// We're on a hot path: don't retry if req fails.
|
||||||
gtscontext.SetFastFail(ctx),
|
gtscontext.SetFastFail(ctx),
|
||||||
requestedUsername,
|
requestedUsername,
|
||||||
|
|
|
@ -639,7 +639,16 @@ func (d *Dereferencer) enrichAccount(
|
||||||
return nil, nil, gtserror.Newf("db error getting account after redirects: %w", err)
|
return nil, nil, gtserror.Newf("db error getting account after redirects: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if alreadyAcc != nil {
|
switch {
|
||||||
|
case alreadyAcc == nil:
|
||||||
|
// nothing to do
|
||||||
|
|
||||||
|
case alreadyAcc.IsLocal():
|
||||||
|
// Request eventually redirected to a
|
||||||
|
// local account. Return it as-is here.
|
||||||
|
return alreadyAcc, nil, nil
|
||||||
|
|
||||||
|
default:
|
||||||
// We had this account stored
|
// We had this account stored
|
||||||
// under discovered final URI.
|
// under discovered final URI.
|
||||||
//
|
//
|
||||||
|
@ -718,12 +727,6 @@ func (d *Dereferencer) enrichAccount(
|
||||||
latestAcc.Username = cmp.Or(latestAcc.Username, accUsername)
|
latestAcc.Username = cmp.Or(latestAcc.Username, accUsername)
|
||||||
}
|
}
|
||||||
|
|
||||||
if latestAcc.Domain == "" {
|
|
||||||
// Ensure we have a domain set by this point,
|
|
||||||
// otherwise it gets stored as a local user!
|
|
||||||
return nil, nil, gtserror.Newf("empty domain for %s", uri)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the final parsed account URI matches
|
// Ensure the final parsed account URI matches
|
||||||
// the input URI we fetched (or received) it as.
|
// the input URI we fetched (or received) it as.
|
||||||
if matches, err := util.URIMatches(
|
if matches, err := util.URIMatches(
|
||||||
|
@ -740,10 +743,16 @@ func (d *Dereferencer) enrichAccount(
|
||||||
} else if !matches {
|
} else if !matches {
|
||||||
return nil, nil, gtserror.Newf(
|
return nil, nil, gtserror.Newf(
|
||||||
"account uri %s does not match %s",
|
"account uri %s does not match %s",
|
||||||
latestAcc.URI, uri.String(),
|
latestAcc.URI, uri,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure this isn't a local account,
|
||||||
|
// or a remote masquerading as such!
|
||||||
|
if latestAcc.IsLocal() {
|
||||||
|
return nil, nil, gtserror.Newf("cannot dereference local account %s", uri)
|
||||||
|
}
|
||||||
|
|
||||||
// Get current time.
|
// Get current time.
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
|
|
|
@ -18,11 +18,15 @@
|
||||||
package dereferencing_test
|
package dereferencing_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
@ -33,6 +37,7 @@
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||||
)
|
)
|
||||||
|
@ -214,6 +219,111 @@ func (suite *AccountTestSuite) TestDereferenceLocalAccountWithUnknownUserURI() {
|
||||||
suite.Nil(fetchedAccount)
|
suite.Nil(fetchedAccount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (suite *AccountTestSuite) TestDereferenceLocalAccountByRedirect() {
|
||||||
|
ctx, cncl := context.WithCancel(context.Background())
|
||||||
|
defer cncl()
|
||||||
|
|
||||||
|
fetchingAccount := suite.testAccounts["local_account_1"]
|
||||||
|
targetAccount := suite.testAccounts["local_account_2"]
|
||||||
|
|
||||||
|
// Convert the target account to ActivityStreams model for dereference.
|
||||||
|
targetAccountable, err := suite.converter.AccountToAS(ctx, targetAccount)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.NotNil(targetAccountable)
|
||||||
|
|
||||||
|
// Serialize to "raw" JSON map for response.
|
||||||
|
rawJSON, err := ap.Serialize(targetAccountable)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Finally serialize to actual bytes.
|
||||||
|
json, err := json.Marshal(rawJSON)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Replace test HTTP client with one that always returns the target account AS model.
|
||||||
|
suite.client = testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
Status: http.StatusText(http.StatusOK),
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
ContentLength: int64(len(json)),
|
||||||
|
Header: http.Header{"Content-Type": {"application/activity+json"}},
|
||||||
|
Body: io.NopCloser(bytes.NewReader(json)),
|
||||||
|
Request: &http.Request{URL: testrig.URLMustParse(targetAccount.URI)},
|
||||||
|
}, nil
|
||||||
|
}, "")
|
||||||
|
|
||||||
|
// Update dereferencer to use new test HTTP client.
|
||||||
|
suite.dereferencer = dereferencing.NewDereferencer(
|
||||||
|
&suite.state,
|
||||||
|
suite.converter,
|
||||||
|
testrig.NewTestTransportController(&suite.state, suite.client),
|
||||||
|
suite.visFilter,
|
||||||
|
suite.intFilter,
|
||||||
|
suite.media,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use any old input test URI, this doesn't actually matter what it is.
|
||||||
|
uri := testrig.URLMustParse("https://this-will-be-redirected.butts/")
|
||||||
|
|
||||||
|
// Try dereference the test URI, since it correctly redirects to us it should return our account.
|
||||||
|
account, accountable, err := suite.dereferencer.GetAccountByURI(ctx, fetchingAccount.Username, uri)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.Nil(accountable)
|
||||||
|
suite.NotNil(account)
|
||||||
|
suite.Equal(targetAccount.ID, account.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *AccountTestSuite) TestDereferenceMasqueradingLocalAccount() {
|
||||||
|
ctx, cncl := context.WithCancel(context.Background())
|
||||||
|
defer cncl()
|
||||||
|
|
||||||
|
fetchingAccount := suite.testAccounts["local_account_1"]
|
||||||
|
targetAccount := suite.testAccounts["local_account_2"]
|
||||||
|
|
||||||
|
// Convert the target account to ActivityStreams model for dereference.
|
||||||
|
targetAccountable, err := suite.converter.AccountToAS(ctx, targetAccount)
|
||||||
|
suite.NoError(err)
|
||||||
|
suite.NotNil(targetAccountable)
|
||||||
|
|
||||||
|
// Serialize to "raw" JSON map for response.
|
||||||
|
rawJSON, err := ap.Serialize(targetAccountable)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Finally serialize to actual bytes.
|
||||||
|
json, err := json.Marshal(rawJSON)
|
||||||
|
suite.NoError(err)
|
||||||
|
|
||||||
|
// Use any old input test URI, this doesn't actually matter what it is.
|
||||||
|
uri := testrig.URLMustParse("https://this-will-be-redirected.butts/")
|
||||||
|
|
||||||
|
// Replace test HTTP client with one that returns OUR account, but at their URI endpoint.
|
||||||
|
suite.client = testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
Status: http.StatusText(http.StatusOK),
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
ContentLength: int64(len(json)),
|
||||||
|
Header: http.Header{"Content-Type": {"application/activity+json"}},
|
||||||
|
Body: io.NopCloser(bytes.NewReader(json)),
|
||||||
|
Request: &http.Request{URL: uri},
|
||||||
|
}, nil
|
||||||
|
}, "")
|
||||||
|
|
||||||
|
// Update dereferencer to use new test HTTP client.
|
||||||
|
suite.dereferencer = dereferencing.NewDereferencer(
|
||||||
|
&suite.state,
|
||||||
|
suite.converter,
|
||||||
|
testrig.NewTestTransportController(&suite.state, suite.client),
|
||||||
|
suite.visFilter,
|
||||||
|
suite.intFilter,
|
||||||
|
suite.media,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Try dereference the test URI, since it correctly redirects to us it should return our account.
|
||||||
|
account, accountable, err := suite.dereferencer.GetAccountByURI(ctx, fetchingAccount.Username, uri)
|
||||||
|
suite.NotNil(err)
|
||||||
|
suite.Nil(account)
|
||||||
|
suite.Nil(accountable)
|
||||||
|
}
|
||||||
|
|
||||||
func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithNonMatchingURI() {
|
func (suite *AccountTestSuite) TestDereferenceRemoteAccountWithNonMatchingURI() {
|
||||||
fetchingAccount := suite.testAccounts["local_account_1"]
|
fetchingAccount := suite.testAccounts["local_account_1"]
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/filter/interaction"
|
"github.com/superseriousbusiness/gotosocial/internal/filter/interaction"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
"github.com/superseriousbusiness/gotosocial/internal/typeutils"
|
||||||
|
@ -38,6 +39,10 @@ type DereferencerStandardTestSuite struct {
|
||||||
storage *storage.Driver
|
storage *storage.Driver
|
||||||
state state.State
|
state state.State
|
||||||
client *testrig.MockHTTPClient
|
client *testrig.MockHTTPClient
|
||||||
|
converter *typeutils.Converter
|
||||||
|
visFilter *visibility.Filter
|
||||||
|
intFilter *interaction.Filter
|
||||||
|
media *media.Manager
|
||||||
|
|
||||||
testRemoteStatuses map[string]vocab.ActivityStreamsNote
|
testRemoteStatuses map[string]vocab.ActivityStreamsNote
|
||||||
testRemotePeople map[string]vocab.ActivityStreamsPerson
|
testRemotePeople map[string]vocab.ActivityStreamsPerson
|
||||||
|
@ -67,12 +72,15 @@ func (suite *DereferencerStandardTestSuite) SetupTest() {
|
||||||
|
|
||||||
suite.db = testrig.NewTestDB(&suite.state)
|
suite.db = testrig.NewTestDB(&suite.state)
|
||||||
|
|
||||||
converter := typeutils.NewConverter(&suite.state)
|
suite.converter = typeutils.NewConverter(&suite.state)
|
||||||
|
suite.visFilter = visibility.NewFilter(&suite.state)
|
||||||
|
suite.intFilter = interaction.NewFilter(&suite.state)
|
||||||
|
suite.media = testrig.NewTestMediaManager(&suite.state)
|
||||||
|
|
||||||
testrig.StartTimelines(
|
testrig.StartTimelines(
|
||||||
&suite.state,
|
&suite.state,
|
||||||
visibility.NewFilter(&suite.state),
|
suite.visFilter,
|
||||||
converter,
|
suite.converter,
|
||||||
)
|
)
|
||||||
|
|
||||||
suite.client = testrig.NewMockHTTPClient(nil, "../../../testrig/media")
|
suite.client = testrig.NewMockHTTPClient(nil, "../../../testrig/media")
|
||||||
|
@ -81,19 +89,16 @@ func (suite *DereferencerStandardTestSuite) SetupTest() {
|
||||||
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
|
suite.state.AdminActions = admin.New(suite.state.DB, &suite.state.Workers)
|
||||||
suite.state.Storage = suite.storage
|
suite.state.Storage = suite.storage
|
||||||
|
|
||||||
visFilter := visibility.NewFilter(&suite.state)
|
|
||||||
intFilter := interaction.NewFilter(&suite.state)
|
|
||||||
media := testrig.NewTestMediaManager(&suite.state)
|
|
||||||
suite.dereferencer = dereferencing.NewDereferencer(
|
suite.dereferencer = dereferencing.NewDereferencer(
|
||||||
&suite.state,
|
&suite.state,
|
||||||
converter,
|
suite.converter,
|
||||||
testrig.NewTestTransportController(
|
testrig.NewTestTransportController(
|
||||||
&suite.state,
|
&suite.state,
|
||||||
suite.client,
|
suite.client,
|
||||||
),
|
),
|
||||||
visFilter,
|
suite.visFilter,
|
||||||
intFilter,
|
suite.intFilter,
|
||||||
media,
|
suite.media,
|
||||||
)
|
)
|
||||||
testrig.StandardDBSetup(suite.db, nil)
|
testrig.StandardDBSetup(suite.db, nil)
|
||||||
}
|
}
|
||||||
|
|
87
internal/federation/federatingdb/block.go
Normal file
87
internal/federation/federatingdb/block.go
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
// GoToSocial
|
||||||
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package federatingdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/superseriousbusiness/activity/streams/vocab"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f *federatingDB) Block(ctx context.Context, blockable vocab.ActivityStreamsBlock) error {
|
||||||
|
log.DebugKV(ctx, "block", serialize{blockable})
|
||||||
|
|
||||||
|
// Extract relevant values from passed ctx.
|
||||||
|
activityContext := getActivityContext(ctx)
|
||||||
|
if activityContext.internal {
|
||||||
|
return nil // Already processed.
|
||||||
|
}
|
||||||
|
|
||||||
|
requesting := activityContext.requestingAcct
|
||||||
|
receiving := activityContext.receivingAcct
|
||||||
|
|
||||||
|
if receiving.IsMoving() {
|
||||||
|
// A Moving account
|
||||||
|
// can't do this.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert received AS block type to internal model.
|
||||||
|
block, err := f.converter.ASBlockToBlock(ctx, blockable)
|
||||||
|
if err != nil {
|
||||||
|
err := gtserror.Newf("error converting from AS type: %w", err)
|
||||||
|
return gtserror.WrapWithCode(http.StatusBadRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure block enacted by correct account.
|
||||||
|
if block.AccountID != requesting.ID {
|
||||||
|
return gtserror.NewfWithCode(http.StatusForbidden, "requester %s is not expected actor %s",
|
||||||
|
requesting.URI, block.Account.URI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure block received by correct account.
|
||||||
|
if block.TargetAccountID != receiving.ID {
|
||||||
|
return gtserror.NewfWithCode(http.StatusForbidden, "receiver %s is not expected object %s",
|
||||||
|
receiving.URI, block.TargetAccount.URI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new ID for block.
|
||||||
|
block.ID = id.NewULID()
|
||||||
|
|
||||||
|
// Insert the new validated block into the database.
|
||||||
|
if err := f.state.DB.PutBlock(ctx, block); err != nil {
|
||||||
|
return gtserror.Newf("error inserting %s into db: %w", block.URI, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push message to worker queue to handle block side-effects.
|
||||||
|
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
|
||||||
|
APObjectType: ap.ActivityBlock,
|
||||||
|
APActivityType: ap.ActivityCreate,
|
||||||
|
GTSModel: block,
|
||||||
|
Receiving: receiving,
|
||||||
|
Requesting: requesting,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -20,9 +20,7 @@
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
|
||||||
"github.com/superseriousbusiness/activity/streams/vocab"
|
"github.com/superseriousbusiness/activity/streams/vocab"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
@ -49,115 +47,36 @@
|
||||||
func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error {
|
func (f *federatingDB) Create(ctx context.Context, asType vocab.Type) error {
|
||||||
log.DebugKV(ctx, "create", serialize{asType})
|
log.DebugKV(ctx, "create", serialize{asType})
|
||||||
|
|
||||||
|
// Cache entry for this activity type's ID for later
|
||||||
|
// checks in the Exist() function if we see it again.
|
||||||
|
f.activityIDs.Set(ap.GetJSONLDId(asType).String(), struct{}{})
|
||||||
|
|
||||||
|
// Extract relevant values from passed ctx.
|
||||||
activityContext := getActivityContext(ctx)
|
activityContext := getActivityContext(ctx)
|
||||||
if activityContext.internal {
|
if activityContext.internal {
|
||||||
return nil // Already processed.
|
return nil // Already processed.
|
||||||
}
|
}
|
||||||
|
|
||||||
requestingAcct := activityContext.requestingAcct
|
requesting := activityContext.requestingAcct
|
||||||
receivingAcct := activityContext.receivingAcct
|
receiving := activityContext.receivingAcct
|
||||||
|
|
||||||
if requestingAcct.IsMoving() {
|
if requesting.IsMoving() {
|
||||||
// A Moving account
|
// A Moving account
|
||||||
// can't do this.
|
// can't do this.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache entry for this create activity ID for later
|
// Cast to the expected types we handle in this func.
|
||||||
// checks in the Exist() function if we see it again.
|
creatable, ok := asType.(vocab.ActivityStreamsCreate)
|
||||||
f.activityIDs.Set(ap.GetJSONLDId(asType).String(), struct{}{})
|
|
||||||
|
|
||||||
switch name := asType.GetTypeName(); name {
|
|
||||||
case ap.ActivityBlock:
|
|
||||||
// BLOCK SOMETHING
|
|
||||||
return f.activityBlock(ctx, asType, receivingAcct, requestingAcct)
|
|
||||||
case ap.ActivityCreate:
|
|
||||||
// CREATE SOMETHING
|
|
||||||
return f.activityCreate(ctx, asType, receivingAcct, requestingAcct)
|
|
||||||
case ap.ActivityFollow:
|
|
||||||
// FOLLOW SOMETHING
|
|
||||||
return f.activityFollow(ctx, asType, receivingAcct, requestingAcct)
|
|
||||||
case ap.ActivityLike:
|
|
||||||
// LIKE SOMETHING
|
|
||||||
return f.activityLike(ctx, asType, receivingAcct, requestingAcct)
|
|
||||||
case ap.ActivityFlag:
|
|
||||||
// FLAG / REPORT SOMETHING
|
|
||||||
return f.activityFlag(ctx, asType, receivingAcct, requestingAcct)
|
|
||||||
default:
|
|
||||||
log.Debugf(ctx, "unhandled object type: %s", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
BLOCK HANDLERS
|
|
||||||
*/
|
|
||||||
|
|
||||||
func (f *federatingDB) activityBlock(ctx context.Context, asType vocab.Type, receiving *gtsmodel.Account, requesting *gtsmodel.Account) error {
|
|
||||||
blockable, ok := asType.(vocab.ActivityStreamsBlock)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return errors.New("activityBlock: could not convert type to block")
|
log.Debugf(ctx, "unhandled object type: %s", asType.GetTypeName())
|
||||||
}
|
|
||||||
|
|
||||||
block, err := f.converter.ASBlockToBlock(ctx, blockable)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("activityBlock: could not convert Block to gts model block")
|
|
||||||
}
|
|
||||||
|
|
||||||
if block.AccountID != requesting.ID {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"activityBlock: requestingAccount %s is not Block actor account %s",
|
|
||||||
requesting.URI, block.Account.URI,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if block.TargetAccountID != receiving.ID {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"activityBlock: inbox account %s is not Block object account %s",
|
|
||||||
receiving.URI, block.TargetAccount.URI,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
block.ID = id.NewULID()
|
|
||||||
|
|
||||||
if err := f.state.DB.PutBlock(ctx, block); err != nil {
|
|
||||||
return fmt.Errorf("activityBlock: database error inserting block: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
|
|
||||||
APObjectType: ap.ActivityBlock,
|
|
||||||
APActivityType: ap.ActivityCreate,
|
|
||||||
GTSModel: block,
|
|
||||||
Receiving: receiving,
|
|
||||||
Requesting: requesting,
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
CREATE HANDLERS
|
|
||||||
*/
|
|
||||||
|
|
||||||
// activityCreate handles asType Create by checking
|
|
||||||
// the Object entries of the Create and calling other
|
|
||||||
// handlers as appropriate.
|
|
||||||
func (f *federatingDB) activityCreate(
|
|
||||||
ctx context.Context,
|
|
||||||
asType vocab.Type,
|
|
||||||
receivingAccount *gtsmodel.Account,
|
|
||||||
requestingAccount *gtsmodel.Account,
|
|
||||||
) error {
|
|
||||||
create, ok := asType.(vocab.ActivityStreamsCreate)
|
|
||||||
if !ok {
|
|
||||||
return gtserror.Newf("could not convert asType %T to ActivityStreamsCreate", asType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var errs gtserror.MultiError
|
var errs gtserror.MultiError
|
||||||
|
|
||||||
// Extract objects from create activity.
|
// Extract objects from create activity.
|
||||||
objects := ap.ExtractObjects(create)
|
objects := ap.ExtractObjects(creatable)
|
||||||
|
|
||||||
// Extract PollOptionables (votes!) from objects slice.
|
// Extract PollOptionables (votes!) from objects slice.
|
||||||
optionables, objects := ap.ExtractPollOptionables(objects)
|
optionables, objects := ap.ExtractPollOptionables(objects)
|
||||||
|
@ -166,8 +85,8 @@ func (f *federatingDB) activityCreate(
|
||||||
// Handle provided poll vote(s) creation, this can
|
// Handle provided poll vote(s) creation, this can
|
||||||
// be for single or multiple votes in the same poll.
|
// be for single or multiple votes in the same poll.
|
||||||
err := f.createPollOptionables(ctx,
|
err := f.createPollOptionables(ctx,
|
||||||
receivingAccount,
|
receiving,
|
||||||
requestingAccount,
|
requesting,
|
||||||
optionables,
|
optionables,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -182,12 +101,12 @@ func (f *federatingDB) activityCreate(
|
||||||
for _, statusable := range statusables {
|
for _, statusable := range statusables {
|
||||||
// Check if this is a forwarded object, i.e. did
|
// Check if this is a forwarded object, i.e. did
|
||||||
// the account making the request also create this?
|
// the account making the request also create this?
|
||||||
forwarded := !isSender(statusable, requestingAccount)
|
forwarded := !isSender(statusable, requesting)
|
||||||
|
|
||||||
// Handle create event for this statusable.
|
// Handle create event for this statusable.
|
||||||
if err := f.createStatusable(ctx,
|
if err := f.createStatusable(ctx,
|
||||||
receivingAccount,
|
receiving,
|
||||||
requestingAccount,
|
requesting,
|
||||||
statusable,
|
statusable,
|
||||||
forwarded,
|
forwarded,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
@ -340,8 +259,7 @@ func (f *federatingDB) createStatusable(
|
||||||
//
|
//
|
||||||
// It does this to try to ensure thread completion, but
|
// It does this to try to ensure thread completion, but
|
||||||
// we have our own thread fetching mechanism anyway.
|
// we have our own thread fetching mechanism anyway.
|
||||||
log.Debugf(ctx,
|
log.Debugf(ctx, "status %s is not relevant to receiver (%v); dropping it",
|
||||||
"status %s is not relevant to receiver (%v); dropping it",
|
|
||||||
ap.GetJSONLDId(statusable), err,
|
ap.GetJSONLDId(statusable), err,
|
||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
|
@ -351,8 +269,7 @@ func (f *federatingDB) createStatusable(
|
||||||
// gauge how much spam is being sent to them.
|
// gauge how much spam is being sent to them.
|
||||||
//
|
//
|
||||||
// TODO: add Prometheus metrics for this.
|
// TODO: add Prometheus metrics for this.
|
||||||
log.Infof(ctx,
|
log.Infof(ctx, "status %s looked like spam (%v); dropping it",
|
||||||
"status %s looked like spam (%v); dropping it",
|
|
||||||
ap.GetJSONLDId(statusable), err,
|
ap.GetJSONLDId(statusable), err,
|
||||||
)
|
)
|
||||||
return nil
|
return nil
|
||||||
|
@ -398,210 +315,3 @@ func (f *federatingDB) createStatusable(
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
FOLLOW HANDLERS
|
|
||||||
*/
|
|
||||||
|
|
||||||
func (f *federatingDB) activityFollow(ctx context.Context, asType vocab.Type, receivingAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account) error {
|
|
||||||
follow, ok := asType.(vocab.ActivityStreamsFollow)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("activityFollow: could not convert type to follow")
|
|
||||||
}
|
|
||||||
|
|
||||||
followRequest, err := f.converter.ASFollowToFollowRequest(ctx, follow)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("activityFollow: could not convert Follow to follow request: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if followRequest.AccountID != requestingAccount.ID {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"activityFollow: requestingAccount %s is not Follow actor account %s",
|
|
||||||
requestingAccount.URI, followRequest.Account.URI,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if followRequest.TargetAccountID != receivingAccount.ID {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"activityFollow: inbox account %s is not Follow object account %s",
|
|
||||||
receivingAccount.URI, followRequest.TargetAccount.URI,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
followRequest.ID = id.NewULID()
|
|
||||||
|
|
||||||
if err := f.state.DB.PutFollowRequest(ctx, followRequest); err != nil {
|
|
||||||
return fmt.Errorf("activityFollow: database error inserting follow request: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
|
|
||||||
APObjectType: ap.ActivityFollow,
|
|
||||||
APActivityType: ap.ActivityCreate,
|
|
||||||
GTSModel: followRequest,
|
|
||||||
Receiving: receivingAccount,
|
|
||||||
Requesting: requestingAccount,
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
LIKE HANDLERS
|
|
||||||
*/
|
|
||||||
|
|
||||||
func (f *federatingDB) activityLike(
|
|
||||||
ctx context.Context,
|
|
||||||
asType vocab.Type,
|
|
||||||
receivingAcct *gtsmodel.Account,
|
|
||||||
requestingAcct *gtsmodel.Account,
|
|
||||||
) error {
|
|
||||||
like, ok := asType.(vocab.ActivityStreamsLike)
|
|
||||||
if !ok {
|
|
||||||
err := gtserror.Newf("could not convert asType %T to ActivityStreamsLike", asType)
|
|
||||||
return gtserror.SetMalformed(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fave, err := f.converter.ASLikeToFave(ctx, like)
|
|
||||||
if err != nil {
|
|
||||||
return gtserror.Newf("could not convert Like to fave: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure requester not trying to
|
|
||||||
// Like on someone else's behalf.
|
|
||||||
if fave.AccountID != requestingAcct.ID {
|
|
||||||
text := fmt.Sprintf(
|
|
||||||
"requestingAcct %s is not Like actor account %s",
|
|
||||||
requestingAcct.URI, fave.Account.URI,
|
|
||||||
)
|
|
||||||
return gtserror.NewErrorForbidden(errors.New(text), text)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !*fave.Status.Local {
|
|
||||||
// Only process likes of local statuses.
|
|
||||||
// TODO: process for remote statuses as well.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure valid Like target for requester.
|
|
||||||
policyResult, err := f.intFilter.StatusLikeable(ctx,
|
|
||||||
requestingAcct,
|
|
||||||
fave.Status,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
err := gtserror.Newf("error seeing if status %s is likeable: %w", fave.Status.ID, err)
|
|
||||||
return gtserror.NewErrorInternalError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if policyResult.Forbidden() {
|
|
||||||
const errText = "requester does not have permission to Like this status"
|
|
||||||
err := gtserror.New(errText)
|
|
||||||
return gtserror.NewErrorForbidden(err, errText)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive pendingApproval
|
|
||||||
// and preapproved status.
|
|
||||||
var (
|
|
||||||
pendingApproval bool
|
|
||||||
preApproved bool
|
|
||||||
)
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case policyResult.WithApproval():
|
|
||||||
// Requester allowed to do
|
|
||||||
// this pending approval.
|
|
||||||
pendingApproval = true
|
|
||||||
|
|
||||||
case policyResult.MatchedOnCollection():
|
|
||||||
// Requester allowed to do this,
|
|
||||||
// but matched on collection.
|
|
||||||
// Preapprove Like and have the
|
|
||||||
// processor send out an Accept.
|
|
||||||
pendingApproval = true
|
|
||||||
preApproved = true
|
|
||||||
|
|
||||||
case policyResult.Permitted():
|
|
||||||
// Requester straight up
|
|
||||||
// permitted to do this,
|
|
||||||
// no need for Accept.
|
|
||||||
pendingApproval = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set appropriate fields
|
|
||||||
// on fave and store it.
|
|
||||||
fave.ID = id.NewULID()
|
|
||||||
fave.PendingApproval = &pendingApproval
|
|
||||||
fave.PreApproved = preApproved
|
|
||||||
|
|
||||||
if err := f.state.DB.PutStatusFave(ctx, fave); err != nil {
|
|
||||||
if errors.Is(err, db.ErrAlreadyExists) {
|
|
||||||
// The fave already exists in the
|
|
||||||
// database, which means we've already
|
|
||||||
// handled side effects. We can just
|
|
||||||
// return nil here and be done with it.
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return gtserror.Newf("db error inserting fave: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
|
|
||||||
APObjectType: ap.ActivityLike,
|
|
||||||
APActivityType: ap.ActivityCreate,
|
|
||||||
GTSModel: fave,
|
|
||||||
Receiving: receivingAcct,
|
|
||||||
Requesting: requestingAcct,
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
FLAG HANDLERS
|
|
||||||
*/
|
|
||||||
|
|
||||||
func (f *federatingDB) activityFlag(ctx context.Context, asType vocab.Type, receivingAccount *gtsmodel.Account, requestingAccount *gtsmodel.Account) error {
|
|
||||||
flag, ok := asType.(vocab.ActivityStreamsFlag)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("activityFlag: could not convert type to flag")
|
|
||||||
}
|
|
||||||
|
|
||||||
report, err := f.converter.ASFlagToReport(ctx, flag)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("activityFlag: could not convert Flag to report: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Requesting account must have at
|
|
||||||
// least two domains from the right
|
|
||||||
// in common with reporting account.
|
|
||||||
if dns.CompareDomainName(
|
|
||||||
requestingAccount.Domain,
|
|
||||||
report.Account.Domain,
|
|
||||||
) < 2 {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"activityFlag: requesting account %s does not share a domain with Flag Actor account %s",
|
|
||||||
requestingAccount.URI, report.Account.URI,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if report.TargetAccountID != receivingAccount.ID {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"activityFlag: inbox account %s is not Flag object account %s",
|
|
||||||
receivingAccount.URI, report.TargetAccount.URI,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
report.ID = id.NewULID()
|
|
||||||
|
|
||||||
if err := f.state.DB.PutReport(ctx, report); err != nil {
|
|
||||||
return fmt.Errorf("activityFlag: database error inserting report: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
|
|
||||||
APObjectType: ap.ActivityFlag,
|
|
||||||
APActivityType: ap.ActivityCreate,
|
|
||||||
GTSModel: report,
|
|
||||||
Receiving: receivingAccount,
|
|
||||||
Requesting: requestingAccount,
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -25,6 +25,7 @@
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
"github.com/superseriousbusiness/activity/streams"
|
"github.com/superseriousbusiness/activity/streams"
|
||||||
|
"github.com/superseriousbusiness/activity/streams/vocab"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/id"
|
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||||
|
@ -115,8 +116,10 @@ func (suite *CreateTestSuite) TestCreateFlag1() {
|
||||||
suite.FailNow(err.Error())
|
suite.FailNow(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
flag := t.(vocab.ActivityStreamsFlag)
|
||||||
|
|
||||||
ctx := createTestContext(reportedAccount, reportingAccount)
|
ctx := createTestContext(reportedAccount, reportingAccount)
|
||||||
if err := suite.federatingDB.Create(ctx, t); err != nil {
|
if err := suite.federatingDB.Flag(ctx, flag); err != nil {
|
||||||
suite.FailNow(err.Error())
|
suite.FailNow(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
"codeberg.org/gruf/go-cache/v3/simple"
|
"codeberg.org/gruf/go-cache/v3/simple"
|
||||||
"github.com/superseriousbusiness/activity/pub"
|
"github.com/superseriousbusiness/activity/pub"
|
||||||
"github.com/superseriousbusiness/activity/streams/vocab"
|
"github.com/superseriousbusiness/activity/streams/vocab"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/filter/interaction"
|
"github.com/superseriousbusiness/gotosocial/internal/filter/interaction"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/filter/spam"
|
"github.com/superseriousbusiness/gotosocial/internal/filter/spam"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||||
|
@ -34,18 +35,20 @@
|
||||||
// DB wraps the pub.Database interface with
|
// DB wraps the pub.Database interface with
|
||||||
// a couple of custom functions for GoToSocial.
|
// a couple of custom functions for GoToSocial.
|
||||||
type DB interface {
|
type DB interface {
|
||||||
// Default functionality.
|
// Default
|
||||||
|
// functionality.
|
||||||
pub.Database
|
pub.Database
|
||||||
|
|
||||||
/*
|
// Federating protocol overridden callback functionality.
|
||||||
Overridden functionality for calling from federatingProtocol.
|
Like(context.Context, vocab.ActivityStreamsLike) error
|
||||||
*/
|
Block(context.Context, vocab.ActivityStreamsBlock) error
|
||||||
|
Follow(context.Context, vocab.ActivityStreamsFollow) error
|
||||||
Undo(ctx context.Context, undo vocab.ActivityStreamsUndo) error
|
Undo(context.Context, vocab.ActivityStreamsUndo) error
|
||||||
Accept(ctx context.Context, accept vocab.ActivityStreamsAccept) error
|
Accept(context.Context, vocab.ActivityStreamsAccept) error
|
||||||
Reject(ctx context.Context, reject vocab.ActivityStreamsReject) error
|
Reject(context.Context, vocab.ActivityStreamsReject) error
|
||||||
Announce(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error
|
Announce(context.Context, vocab.ActivityStreamsAnnounce) error
|
||||||
Move(ctx context.Context, move vocab.ActivityStreamsMove) error
|
Move(context.Context, vocab.ActivityStreamsMove) error
|
||||||
|
Flag(context.Context, vocab.ActivityStreamsFlag) error
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Extra/convenience functionality.
|
Extra/convenience functionality.
|
||||||
|
@ -87,3 +90,9 @@ func New(
|
||||||
fdb.activityIDs.Init(0, 2048)
|
fdb.activityIDs.Init(0, 2048)
|
||||||
return &fdb
|
return &fdb
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storeActivityID stores an entry in the .activityIDs cache for this
|
||||||
|
// type's JSON-LD ID, for later checks in Exist() to mark it as seen.
|
||||||
|
func (f *federatingDB) storeActivityID(asType vocab.Type) {
|
||||||
|
f.activityIDs.Set(ap.GetJSONLDId(asType).String(), struct{}{})
|
||||||
|
}
|
||||||
|
|
91
internal/federation/federatingdb/flag.go
Normal file
91
internal/federation/federatingdb/flag.go
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
// GoToSocial
|
||||||
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package federatingdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/superseriousbusiness/activity/streams/vocab"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f *federatingDB) Flag(ctx context.Context, flaggable vocab.ActivityStreamsFlag) error {
|
||||||
|
log.DebugKV(ctx, "flag", serialize{flaggable})
|
||||||
|
|
||||||
|
// Mark activity as handled.
|
||||||
|
f.storeActivityID(flaggable)
|
||||||
|
|
||||||
|
// Extract relevant values from passed ctx.
|
||||||
|
activityContext := getActivityContext(ctx)
|
||||||
|
if activityContext.internal {
|
||||||
|
return nil // Already processed.
|
||||||
|
}
|
||||||
|
|
||||||
|
requesting := activityContext.requestingAcct
|
||||||
|
receiving := activityContext.receivingAcct
|
||||||
|
|
||||||
|
// Convert received AS flag type to internal report model.
|
||||||
|
report, err := f.converter.ASFlagToReport(ctx, flaggable)
|
||||||
|
if err != nil {
|
||||||
|
err := gtserror.Newf("error converting from AS type: %w", err)
|
||||||
|
return gtserror.WrapWithCode(http.StatusBadRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requesting acc's domain must be at
|
||||||
|
// least a subdomain of the reporting
|
||||||
|
// account. i.e. if they're using a
|
||||||
|
// different account domain to host.
|
||||||
|
if dns.CompareDomainName(
|
||||||
|
requesting.Domain,
|
||||||
|
report.Account.Domain,
|
||||||
|
) < 2 {
|
||||||
|
return gtserror.NewfWithCode(http.StatusForbidden, "requester %s does not share a domain with Flag Actor account %s",
|
||||||
|
requesting.URI, report.Account.URI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure report received by correct account.
|
||||||
|
if report.TargetAccountID != receiving.ID {
|
||||||
|
return gtserror.NewfWithCode(http.StatusForbidden, "receiver %s is not expected object %s",
|
||||||
|
receiving.URI, report.TargetAccount.URI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new ID for report.
|
||||||
|
report.ID = id.NewULID()
|
||||||
|
|
||||||
|
// Insert the new validated reported into the database.
|
||||||
|
if err := f.state.DB.PutReport(ctx, report); err != nil {
|
||||||
|
return gtserror.Newf("error inserting %s into db: %w", report.URI, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push message to worker queue to handle report side-effects.
|
||||||
|
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
|
||||||
|
APObjectType: ap.ActivityFlag,
|
||||||
|
APActivityType: ap.ActivityCreate,
|
||||||
|
GTSModel: report,
|
||||||
|
Receiving: receiving,
|
||||||
|
Requesting: requesting,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
84
internal/federation/federatingdb/follow.go
Normal file
84
internal/federation/federatingdb/follow.go
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
// GoToSocial
|
||||||
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package federatingdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/superseriousbusiness/activity/streams/vocab"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f *federatingDB) Follow(ctx context.Context, followable vocab.ActivityStreamsFollow) error {
|
||||||
|
log.DebugKV(ctx, "follow", serialize{followable})
|
||||||
|
|
||||||
|
// Mark activity as handled.
|
||||||
|
f.storeActivityID(followable)
|
||||||
|
|
||||||
|
// Extract relevant values from passed ctx.
|
||||||
|
activityContext := getActivityContext(ctx)
|
||||||
|
if activityContext.internal {
|
||||||
|
return nil // Already processed.
|
||||||
|
}
|
||||||
|
|
||||||
|
requesting := activityContext.requestingAcct
|
||||||
|
receiving := activityContext.receivingAcct
|
||||||
|
|
||||||
|
// Convert received AS block type to internal follow request model.
|
||||||
|
followreq, err := f.converter.ASFollowToFollowRequest(ctx, followable)
|
||||||
|
if err != nil {
|
||||||
|
err := gtserror.Newf("error converting from AS type: %w", err)
|
||||||
|
return gtserror.WrapWithCode(http.StatusBadRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure follow enacted by correct account.
|
||||||
|
if followreq.AccountID != requesting.ID {
|
||||||
|
return gtserror.NewfWithCode(http.StatusForbidden, "requester %s is not expected actor %s",
|
||||||
|
requesting.URI, followreq.Account.URI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure follow received by correct account.
|
||||||
|
if followreq.TargetAccountID != receiving.ID {
|
||||||
|
return gtserror.NewfWithCode(http.StatusForbidden, "receiver %s is not expected object %s",
|
||||||
|
receiving.URI, followreq.TargetAccount.URI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new ID for followreq.
|
||||||
|
followreq.ID = id.NewULID()
|
||||||
|
|
||||||
|
// Insert the new validate follow request into the database.
|
||||||
|
if err := f.state.DB.PutFollowRequest(ctx, followreq); err != nil {
|
||||||
|
return gtserror.Newf("error inserting %s into db: %w", followreq.URI, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push message to worker queue to handle followreq side-effects.
|
||||||
|
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
|
||||||
|
APObjectType: ap.ActivityFollow,
|
||||||
|
APActivityType: ap.ActivityCreate,
|
||||||
|
GTSModel: followreq,
|
||||||
|
Receiving: receiving,
|
||||||
|
Requesting: requesting,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
147
internal/federation/federatingdb/like.go
Normal file
147
internal/federation/federatingdb/like.go
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
// GoToSocial
|
||||||
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package federatingdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/superseriousbusiness/activity/streams/vocab"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/ap"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/gtserror"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/id"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f *federatingDB) Like(ctx context.Context, likeable vocab.ActivityStreamsLike) error {
|
||||||
|
log.DebugKV(ctx, "like", serialize{likeable})
|
||||||
|
|
||||||
|
// Mark activity as handled.
|
||||||
|
f.storeActivityID(likeable)
|
||||||
|
|
||||||
|
// Extract relevant values from passed ctx.
|
||||||
|
activityContext := getActivityContext(ctx)
|
||||||
|
if activityContext.internal {
|
||||||
|
return nil // Already processed.
|
||||||
|
}
|
||||||
|
|
||||||
|
requesting := activityContext.requestingAcct
|
||||||
|
receiving := activityContext.receivingAcct
|
||||||
|
|
||||||
|
if receiving.IsMoving() {
|
||||||
|
// A Moving account
|
||||||
|
// can't do this.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert received AS like type to internal fave model.
|
||||||
|
fave, err := f.converter.ASLikeToFave(ctx, likeable)
|
||||||
|
if err != nil {
|
||||||
|
err := gtserror.Newf("error converting from AS type: %w", err)
|
||||||
|
return gtserror.WrapWithCode(http.StatusBadRequest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure fave enacted by correct account.
|
||||||
|
if fave.AccountID != requesting.ID {
|
||||||
|
return gtserror.NewfWithCode(http.StatusForbidden, "requester %s is not expected actor %s",
|
||||||
|
requesting.URI, fave.Account.URI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure fave received by correct account.
|
||||||
|
if fave.TargetAccountID != receiving.ID {
|
||||||
|
return gtserror.NewfWithCode(http.StatusForbidden, "receiver %s is not expected object %s",
|
||||||
|
receiving.URI, fave.TargetAccount.URI)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !*fave.Status.Local {
|
||||||
|
// Only process likes of local statuses.
|
||||||
|
// TODO: process for remote statuses as well.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure valid Like target for requester.
|
||||||
|
policyResult, err := f.intFilter.StatusLikeable(ctx,
|
||||||
|
requesting,
|
||||||
|
fave.Status,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return gtserror.Newf("error seeing if status %s is likeable: %w", fave.Status.URI, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if policyResult.Forbidden() {
|
||||||
|
return gtserror.NewWithCode(http.StatusForbidden, "requester does not have permission to Like status")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive pendingApproval
|
||||||
|
// and preapproved status.
|
||||||
|
var (
|
||||||
|
pendingApproval bool
|
||||||
|
preApproved bool
|
||||||
|
)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case policyResult.WithApproval():
|
||||||
|
// Requester allowed to do
|
||||||
|
// this pending approval.
|
||||||
|
pendingApproval = true
|
||||||
|
|
||||||
|
case policyResult.MatchedOnCollection():
|
||||||
|
// Requester allowed to do this,
|
||||||
|
// but matched on collection.
|
||||||
|
// Preapprove Like and have the
|
||||||
|
// processor send out an Accept.
|
||||||
|
pendingApproval = true
|
||||||
|
preApproved = true
|
||||||
|
|
||||||
|
case policyResult.Permitted():
|
||||||
|
// Requester straight up
|
||||||
|
// permitted to do this,
|
||||||
|
// no need for Accept.
|
||||||
|
pendingApproval = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set appropriate fields
|
||||||
|
// on fave and store it.
|
||||||
|
fave.ID = id.NewULID()
|
||||||
|
fave.PendingApproval = &pendingApproval
|
||||||
|
fave.PreApproved = preApproved
|
||||||
|
|
||||||
|
if err := f.state.DB.PutStatusFave(ctx, fave); err != nil {
|
||||||
|
if errors.Is(err, db.ErrAlreadyExists) {
|
||||||
|
// The fave already exists in the
|
||||||
|
// database, which means we've already
|
||||||
|
// handled side effects. We can just
|
||||||
|
// return nil here and be done with it.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return gtserror.Newf("error inserting %s into db: %w", fave.URI, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f.state.Workers.Federator.Queue.Push(&messages.FromFediAPI{
|
||||||
|
APObjectType: ap.ActivityLike,
|
||||||
|
APActivityType: ap.ActivityCreate,
|
||||||
|
GTSModel: fave,
|
||||||
|
Receiving: receiving,
|
||||||
|
Requesting: requesting,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -38,6 +38,9 @@
|
||||||
func (f *federatingDB) Move(ctx context.Context, move vocab.ActivityStreamsMove) error {
|
func (f *federatingDB) Move(ctx context.Context, move vocab.ActivityStreamsMove) error {
|
||||||
log.DebugKV(ctx, "move", serialize{move})
|
log.DebugKV(ctx, "move", serialize{move})
|
||||||
|
|
||||||
|
// Mark activity as handled.
|
||||||
|
f.storeActivityID(move)
|
||||||
|
|
||||||
activityContext := getActivityContext(ctx)
|
activityContext := getActivityContext(ctx)
|
||||||
if activityContext.internal {
|
if activityContext.internal {
|
||||||
// Already processed.
|
// Already processed.
|
||||||
|
|
|
@ -43,20 +43,24 @@
|
||||||
func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error {
|
func (f *federatingDB) Update(ctx context.Context, asType vocab.Type) error {
|
||||||
log.DebugKV(ctx, "update", serialize{asType})
|
log.DebugKV(ctx, "update", serialize{asType})
|
||||||
|
|
||||||
|
// Mark activity as handled.
|
||||||
|
f.storeActivityID(asType)
|
||||||
|
|
||||||
|
// Extract relevant values from passed ctx.
|
||||||
activityContext := getActivityContext(ctx)
|
activityContext := getActivityContext(ctx)
|
||||||
if activityContext.internal {
|
if activityContext.internal {
|
||||||
return nil // Already processed.
|
return nil // Already processed.
|
||||||
}
|
}
|
||||||
|
|
||||||
requestingAcct := activityContext.requestingAcct
|
requesting := activityContext.requestingAcct
|
||||||
receivingAcct := activityContext.receivingAcct
|
receiving := activityContext.receivingAcct
|
||||||
|
|
||||||
if accountable, ok := ap.ToAccountable(asType); ok {
|
if accountable, ok := ap.ToAccountable(asType); ok {
|
||||||
return f.updateAccountable(ctx, receivingAcct, requestingAcct, accountable)
|
return f.updateAccountable(ctx, receiving, requesting, accountable)
|
||||||
}
|
}
|
||||||
|
|
||||||
if statusable, ok := ap.ToStatusable(asType); ok {
|
if statusable, ok := ap.ToStatusable(asType); ok {
|
||||||
return f.updateStatusable(ctx, receivingAcct, requestingAcct, statusable)
|
return f.updateStatusable(ctx, receiving, requesting, statusable)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debugf(ctx, "unhandled object type: %T", asType)
|
log.Debugf(ctx, "unhandled object type: %T", asType)
|
||||||
|
|
|
@ -456,39 +456,8 @@ func (f *Federator) FederatingCallbacks(ctx context.Context) (
|
||||||
other []any,
|
other []any,
|
||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
wrapped = pub.FederatingWrappedCallbacks{
|
wrapped = f.wrapped
|
||||||
// OnFollow determines what action to take for this
|
other = f.callback
|
||||||
// particular callback if a Follow Activity is handled.
|
|
||||||
//
|
|
||||||
// For our implementation, we always want to do nothing
|
|
||||||
// because we have internal logic for handling follows.
|
|
||||||
OnFollow: pub.OnFollowDoNothing,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override some default behaviors to trigger our own side effects.
|
|
||||||
other = []any{
|
|
||||||
func(ctx context.Context, undo vocab.ActivityStreamsUndo) error {
|
|
||||||
return f.FederatingDB().Undo(ctx, undo)
|
|
||||||
},
|
|
||||||
func(ctx context.Context, accept vocab.ActivityStreamsAccept) error {
|
|
||||||
return f.FederatingDB().Accept(ctx, accept)
|
|
||||||
},
|
|
||||||
func(ctx context.Context, reject vocab.ActivityStreamsReject) error {
|
|
||||||
return f.FederatingDB().Reject(ctx, reject)
|
|
||||||
},
|
|
||||||
func(ctx context.Context, announce vocab.ActivityStreamsAnnounce) error {
|
|
||||||
return f.FederatingDB().Announce(ctx, announce)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define some of our own behaviors which are not
|
|
||||||
// overrides of the default pub.FederatingWrappedCallbacks.
|
|
||||||
other = append(other, []any{
|
|
||||||
func(ctx context.Context, move vocab.ActivityStreamsMove) error {
|
|
||||||
return f.FederatingDB().Move(ctx, move)
|
|
||||||
},
|
|
||||||
}...)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -40,10 +40,15 @@ type Federator struct {
|
||||||
federatingDB federatingdb.DB
|
federatingDB federatingdb.DB
|
||||||
clock pub.Clock
|
clock pub.Clock
|
||||||
converter *typeutils.Converter
|
converter *typeutils.Converter
|
||||||
transportController transport.Controller
|
transport transport.Controller
|
||||||
mediaManager *media.Manager
|
mediaManager *media.Manager
|
||||||
actor pub.FederatingActor
|
actor pub.FederatingActor
|
||||||
dereferencing.Dereferencer
|
dereferencing.Dereferencer
|
||||||
|
|
||||||
|
// store result of FederatingCallbacks() ahead
|
||||||
|
// of time since it's called in every PostInbox().
|
||||||
|
wrapped pub.FederatingWrappedCallbacks
|
||||||
|
callback []any
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFederator returns a new federator instance.
|
// NewFederator returns a new federator instance.
|
||||||
|
@ -62,8 +67,9 @@ func NewFederator(
|
||||||
federatingDB: federatingDB,
|
federatingDB: federatingDB,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
converter: converter,
|
converter: converter,
|
||||||
transportController: transportController,
|
transport: transportController,
|
||||||
mediaManager: mediaManager,
|
mediaManager: mediaManager,
|
||||||
|
|
||||||
Dereferencer: dereferencing.NewDereferencer(
|
Dereferencer: dereferencing.NewDereferencer(
|
||||||
state,
|
state,
|
||||||
converter,
|
converter,
|
||||||
|
@ -72,6 +78,28 @@ func NewFederator(
|
||||||
intFilter,
|
intFilter,
|
||||||
mediaManager,
|
mediaManager,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// prepared response to FederatingCallbacks()
|
||||||
|
wrapped: pub.FederatingWrappedCallbacks{
|
||||||
|
|
||||||
|
// OnFollow determines what action to take for this
|
||||||
|
// particular callback if a Follow Activity is handled.
|
||||||
|
//
|
||||||
|
// For our implementation, we always want to do nothing
|
||||||
|
// because we have internal logic for handling follows.
|
||||||
|
OnFollow: pub.OnFollowDoNothing,
|
||||||
|
},
|
||||||
|
callback: []any{
|
||||||
|
federatingDB.Like,
|
||||||
|
federatingDB.Block,
|
||||||
|
federatingDB.Follow,
|
||||||
|
federatingDB.Undo,
|
||||||
|
federatingDB.Accept,
|
||||||
|
federatingDB.Reject,
|
||||||
|
federatingDB.Announce,
|
||||||
|
federatingDB.Move,
|
||||||
|
federatingDB.Flag,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
actor := newFederatingActor(f, f, federatingDB, clock)
|
actor := newFederatingActor(f, f, federatingDB, clock)
|
||||||
f.actor = actor
|
f.actor = actor
|
||||||
|
@ -90,5 +118,5 @@ func (f *Federator) FederatingDB() federatingdb.DB {
|
||||||
|
|
||||||
// TransportController returns the underlying transport controller.
|
// TransportController returns the underlying transport controller.
|
||||||
func (f *Federator) TransportController() transport.Controller {
|
func (f *Federator) TransportController() transport.Controller {
|
||||||
return f.transportController
|
return f.transport
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,5 +68,5 @@ func (f *Federator) NewTransport(ctx context.Context, actorBoxIRI *url.URL, _ st
|
||||||
return nil, fmt.Errorf("id %s was neither an inbox path nor an outbox path", actorBoxIRI.String())
|
return nil, fmt.Errorf("id %s was neither an inbox path nor an outbox path", actorBoxIRI.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
return f.transportController.NewTransportForUsername(ctx, username)
|
return f.transport.NewTransportForUsername(ctx, username)
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
package gtserror
|
package gtserror
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
@ -53,36 +53,77 @@ type WithCode interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type withCode struct {
|
type withCode struct {
|
||||||
original error
|
err error
|
||||||
safe error
|
safe string
|
||||||
code int
|
code int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e withCode) Unwrap() error {
|
func (e *withCode) Unwrap() error {
|
||||||
return e.original
|
return e.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e withCode) Error() string {
|
func (e *withCode) Error() string {
|
||||||
return e.original.Error()
|
return e.err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e withCode) Safe() string {
|
func (e *withCode) Safe() string {
|
||||||
return e.safe.Error()
|
return e.safe
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e withCode) Code() int {
|
func (e *withCode) Code() int {
|
||||||
return e.code
|
return e.code
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewWithCode returns a new gtserror.WithCode that implements the error interface
|
||||||
|
// with given HTTP status code, providing status message of "${httpStatus}: ${msg}".
|
||||||
|
func NewWithCode(code int, msg string) WithCode {
|
||||||
|
return &withCode{
|
||||||
|
err: newAt(3, msg),
|
||||||
|
safe: http.StatusText(code) + ": " + msg,
|
||||||
|
code: code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewfWithCode returns a new formatted gtserror.WithCode that implements the error interface
|
||||||
|
// with given HTTP status code, provided formatted status message of "${httpStatus}: ${msg}".
|
||||||
|
func NewfWithCode(code int, msgf string, args ...any) WithCode {
|
||||||
|
msg := fmt.Sprintf(msgf, args...)
|
||||||
|
return &withCode{
|
||||||
|
err: newAt(3, msg),
|
||||||
|
safe: http.StatusText(code) + ": " + msg,
|
||||||
|
code: code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWithCodeSafe returns a new gtserror.WithCode wrapping error with given HTTP status
|
||||||
|
// code, hiding error message externally, providing status message of "${httpStatus}: ${safe}".
|
||||||
|
func NewWithCodeSafe(code int, err error, safe string) WithCode {
|
||||||
|
return &withCode{
|
||||||
|
err: err,
|
||||||
|
safe: http.StatusText(code) + ": " + safe,
|
||||||
|
code: code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapWithCode returns a new gtserror.WithCode wrapping error with given HTTP
|
||||||
|
// status code, hiding error message externally, providing standard status message.
|
||||||
|
func WrapWithCode(code int, err error) WithCode {
|
||||||
|
return &withCode{
|
||||||
|
err: err,
|
||||||
|
safe: http.StatusText(code),
|
||||||
|
code: code,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewErrorBadRequest returns an ErrorWithCode 400 with the given original error and optional help text.
|
// NewErrorBadRequest returns an ErrorWithCode 400 with the given original error and optional help text.
|
||||||
func NewErrorBadRequest(original error, helpText ...string) WithCode {
|
func NewErrorBadRequest(original error, helpText ...string) WithCode {
|
||||||
safe := http.StatusText(http.StatusBadRequest)
|
safe := http.StatusText(http.StatusBadRequest)
|
||||||
if helpText != nil {
|
if helpText != nil {
|
||||||
safe = safe + ": " + strings.Join(helpText, ": ")
|
safe = safe + ": " + strings.Join(helpText, ": ")
|
||||||
}
|
}
|
||||||
return withCode{
|
return &withCode{
|
||||||
original: original,
|
err: original,
|
||||||
safe: errors.New(safe),
|
safe: safe,
|
||||||
code: http.StatusBadRequest,
|
code: http.StatusBadRequest,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -93,9 +134,9 @@ func NewErrorUnauthorized(original error, helpText ...string) WithCode {
|
||||||
if helpText != nil {
|
if helpText != nil {
|
||||||
safe = safe + ": " + strings.Join(helpText, ": ")
|
safe = safe + ": " + strings.Join(helpText, ": ")
|
||||||
}
|
}
|
||||||
return withCode{
|
return &withCode{
|
||||||
original: original,
|
err: original,
|
||||||
safe: errors.New(safe),
|
safe: safe,
|
||||||
code: http.StatusUnauthorized,
|
code: http.StatusUnauthorized,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -106,9 +147,9 @@ func NewErrorForbidden(original error, helpText ...string) WithCode {
|
||||||
if helpText != nil {
|
if helpText != nil {
|
||||||
safe = safe + ": " + strings.Join(helpText, ": ")
|
safe = safe + ": " + strings.Join(helpText, ": ")
|
||||||
}
|
}
|
||||||
return withCode{
|
return &withCode{
|
||||||
original: original,
|
err: original,
|
||||||
safe: errors.New(safe),
|
safe: safe,
|
||||||
code: http.StatusForbidden,
|
code: http.StatusForbidden,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -119,9 +160,9 @@ func NewErrorNotFound(original error, helpText ...string) WithCode {
|
||||||
if helpText != nil {
|
if helpText != nil {
|
||||||
safe = safe + ": " + strings.Join(helpText, ": ")
|
safe = safe + ": " + strings.Join(helpText, ": ")
|
||||||
}
|
}
|
||||||
return withCode{
|
return &withCode{
|
||||||
original: original,
|
err: original,
|
||||||
safe: errors.New(safe),
|
safe: safe,
|
||||||
code: http.StatusNotFound,
|
code: http.StatusNotFound,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -132,9 +173,9 @@ func NewErrorInternalError(original error, helpText ...string) WithCode {
|
||||||
if helpText != nil {
|
if helpText != nil {
|
||||||
safe = safe + ": " + strings.Join(helpText, ": ")
|
safe = safe + ": " + strings.Join(helpText, ": ")
|
||||||
}
|
}
|
||||||
return withCode{
|
return &withCode{
|
||||||
original: original,
|
err: original,
|
||||||
safe: errors.New(safe),
|
safe: safe,
|
||||||
code: http.StatusInternalServerError,
|
code: http.StatusInternalServerError,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -145,9 +186,9 @@ func NewErrorConflict(original error, helpText ...string) WithCode {
|
||||||
if helpText != nil {
|
if helpText != nil {
|
||||||
safe = safe + ": " + strings.Join(helpText, ": ")
|
safe = safe + ": " + strings.Join(helpText, ": ")
|
||||||
}
|
}
|
||||||
return withCode{
|
return &withCode{
|
||||||
original: original,
|
err: original,
|
||||||
safe: errors.New(safe),
|
safe: safe,
|
||||||
code: http.StatusConflict,
|
code: http.StatusConflict,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -158,9 +199,9 @@ func NewErrorNotAcceptable(original error, helpText ...string) WithCode {
|
||||||
if helpText != nil {
|
if helpText != nil {
|
||||||
safe = safe + ": " + strings.Join(helpText, ": ")
|
safe = safe + ": " + strings.Join(helpText, ": ")
|
||||||
}
|
}
|
||||||
return withCode{
|
return &withCode{
|
||||||
original: original,
|
err: original,
|
||||||
safe: errors.New(safe),
|
safe: safe,
|
||||||
code: http.StatusNotAcceptable,
|
code: http.StatusNotAcceptable,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -171,9 +212,9 @@ func NewErrorUnprocessableEntity(original error, helpText ...string) WithCode {
|
||||||
if helpText != nil {
|
if helpText != nil {
|
||||||
safe = safe + ": " + strings.Join(helpText, ": ")
|
safe = safe + ": " + strings.Join(helpText, ": ")
|
||||||
}
|
}
|
||||||
return withCode{
|
return &withCode{
|
||||||
original: original,
|
err: original,
|
||||||
safe: errors.New(safe),
|
safe: safe,
|
||||||
code: http.StatusUnprocessableEntity,
|
code: http.StatusUnprocessableEntity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -184,9 +225,9 @@ func NewErrorGone(original error, helpText ...string) WithCode {
|
||||||
if helpText != nil {
|
if helpText != nil {
|
||||||
safe = safe + ": " + strings.Join(helpText, ": ")
|
safe = safe + ": " + strings.Join(helpText, ": ")
|
||||||
}
|
}
|
||||||
return withCode{
|
return &withCode{
|
||||||
original: original,
|
err: original,
|
||||||
safe: errors.New(safe),
|
safe: safe,
|
||||||
code: http.StatusGone,
|
code: http.StatusGone,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -197,9 +238,9 @@ func NewErrorNotImplemented(original error, helpText ...string) WithCode {
|
||||||
if helpText != nil {
|
if helpText != nil {
|
||||||
safe = safe + ": " + strings.Join(helpText, ": ")
|
safe = safe + ": " + strings.Join(helpText, ": ")
|
||||||
}
|
}
|
||||||
return withCode{
|
return &withCode{
|
||||||
original: original,
|
err: original,
|
||||||
safe: errors.New(safe),
|
safe: safe,
|
||||||
code: http.StatusNotImplemented,
|
code: http.StatusNotImplemented,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -208,9 +249,9 @@ func NewErrorNotImplemented(original error, helpText ...string) WithCode {
|
||||||
// This error type should only be used when an http caller has already hung up their request.
|
// This error type should only be used when an http caller has already hung up their request.
|
||||||
// See: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#nginx
|
// See: https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#nginx
|
||||||
func NewErrorClientClosedRequest(original error) WithCode {
|
func NewErrorClientClosedRequest(original error) WithCode {
|
||||||
return withCode{
|
return &withCode{
|
||||||
original: original,
|
err: original,
|
||||||
safe: errors.New(StatusTextClientClosedRequest),
|
safe: StatusTextClientClosedRequest,
|
||||||
code: StatusClientClosedRequest,
|
code: StatusClientClosedRequest,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -219,9 +260,9 @@ func NewErrorClientClosedRequest(original error) WithCode {
|
||||||
// This error type should only be used when the server has decided to hang up a client
|
// This error type should only be used when the server has decided to hang up a client
|
||||||
// request after x amount of time, to avoid keeping extremely slow client requests open.
|
// request after x amount of time, to avoid keeping extremely slow client requests open.
|
||||||
func NewErrorRequestTimeout(original error) WithCode {
|
func NewErrorRequestTimeout(original error) WithCode {
|
||||||
return withCode{
|
return &withCode{
|
||||||
original: original,
|
err: original,
|
||||||
safe: errors.New(http.StatusText(http.StatusRequestTimeout)),
|
safe: http.StatusText(http.StatusRequestTimeout),
|
||||||
code: http.StatusRequestTimeout,
|
code: http.StatusRequestTimeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,8 @@
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc).
|
// Account represents either a local or a remote fediverse
|
||||||
|
// account, gotosocial or otherwise (mastodon, pleroma, etc).
|
||||||
type Account struct {
|
type Account struct {
|
||||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
|
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
|
||||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created.
|
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created.
|
||||||
|
@ -83,9 +84,19 @@ type Account struct {
|
||||||
Stats *AccountStats `bun:"-"` // gtsmodel.AccountStats for this account.
|
Stats *AccountStats `bun:"-"` // gtsmodel.AccountStats for this account.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UsernameDomain returns account @username@domain (missing domain if local).
|
||||||
|
func (a *Account) UsernameDomain() string {
|
||||||
|
if a.IsLocal() {
|
||||||
|
return "@" + a.Username
|
||||||
|
}
|
||||||
|
return "@" + a.Username + "@" + a.Domain
|
||||||
|
}
|
||||||
|
|
||||||
// IsLocal returns whether account is a local user account.
|
// IsLocal returns whether account is a local user account.
|
||||||
func (a *Account) IsLocal() bool {
|
func (a *Account) IsLocal() bool {
|
||||||
return a.Domain == "" || a.Domain == config.GetHost() || a.Domain == config.GetAccountDomain()
|
return a.Domain == "" ||
|
||||||
|
a.Domain == config.GetHost() ||
|
||||||
|
a.Domain == config.GetAccountDomain()
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsRemote returns whether account is a remote user account.
|
// IsRemote returns whether account is a remote user account.
|
||||||
|
|
|
@ -19,7 +19,8 @@
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// Emoji represents a custom emoji that's been uploaded through the admin UI or downloaded from a remote instance.
|
// Emoji represents a custom emoji that's been uploaded
|
||||||
|
// through the admin UI or downloaded from a remote instance.
|
||||||
type Emoji struct {
|
type Emoji struct {
|
||||||
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
|
ID string `bun:"type:CHAR(26),pk,nullzero,notnull,unique"` // id of this item in the database
|
||||||
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
|
CreatedAt time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created
|
||||||
|
|
|
@ -65,17 +65,31 @@ func (p *Processor) NodeInfoRelGet(ctx context.Context) (*apimodel.WellKnownResp
|
||||||
|
|
||||||
// NodeInfoGet returns a node info struct in response to a node info request.
|
// NodeInfoGet returns a node info struct in response to a node info request.
|
||||||
func (p *Processor) NodeInfoGet(ctx context.Context) (*apimodel.Nodeinfo, gtserror.WithCode) {
|
func (p *Processor) NodeInfoGet(ctx context.Context) (*apimodel.Nodeinfo, gtserror.WithCode) {
|
||||||
|
var (
|
||||||
|
userCount int
|
||||||
|
postCount int
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.GetInstanceStatsRandomize() {
|
||||||
|
// Use randomized stats.
|
||||||
|
stats := p.converter.RandomStats()
|
||||||
|
userCount = int(stats.TotalUsers)
|
||||||
|
postCount = int(stats.Statuses)
|
||||||
|
} else {
|
||||||
|
// Count actual stats.
|
||||||
host := config.GetHost()
|
host := config.GetHost()
|
||||||
|
|
||||||
userCount, err := p.state.DB.CountInstanceUsers(ctx, host)
|
userCount, err = p.state.DB.CountInstanceUsers(ctx, host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
postCount, err := p.state.DB.CountInstanceStatuses(ctx, host)
|
postCount, err = p.state.DB.CountInstanceStatuses(ctx, host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &apimodel.Nodeinfo{
|
return &apimodel.Nodeinfo{
|
||||||
Version: nodeInfoVersion,
|
Version: nodeInfoVersion,
|
||||||
|
|
|
@ -48,6 +48,7 @@
|
||||||
type Router struct {
|
type Router struct {
|
||||||
engine *gin.Engine
|
engine *gin.Engine
|
||||||
srv *http.Server
|
srv *http.Server
|
||||||
|
leSrv *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a new Router, which wraps
|
// New returns a new Router, which wraps
|
||||||
|
@ -185,15 +186,38 @@ func (r *Router) Start() error {
|
||||||
|
|
||||||
// Stop shuts down the router nicely.
|
// Stop shuts down the router nicely.
|
||||||
func (r *Router) Stop() error {
|
func (r *Router) Stop() error {
|
||||||
log.Infof(nil, "shutting down http router with %s grace period", shutdownTimeout)
|
ctx := context.Background()
|
||||||
timeout, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := r.srv.Shutdown(timeout); err != nil {
|
// Shut down "main" server.
|
||||||
return fmt.Errorf("error shutting down http router: %s", err)
|
if err := stopServer(ctx, r.srv, "http server"); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info(nil, "http router closed connections and shut down gracefully")
|
// Shut down letsencrypt
|
||||||
|
// server if enabled.
|
||||||
|
if r.leSrv != nil {
|
||||||
|
if err := stopServer(ctx, r.leSrv, "letsencrypt http server"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopServer(
|
||||||
|
ctx context.Context,
|
||||||
|
s *http.Server,
|
||||||
|
name string,
|
||||||
|
) error {
|
||||||
|
timeout, cancel := context.WithTimeout(ctx, shutdownTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
log.Infof(nil, "shutting down %s with %s grace period", name, shutdownTimeout)
|
||||||
|
if err := s.Shutdown(timeout); err != nil {
|
||||||
|
return fmt.Errorf("error shutting down %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof(ctx, "%s closed connections and shut down gracefully", name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -228,8 +252,8 @@ func (r *Router) customTLS(
|
||||||
// letsEncryptTLS modifies the router's underlying http
|
// letsEncryptTLS modifies the router's underlying http
|
||||||
// server to use LetsEncrypt via an ACME Autocert manager.
|
// server to use LetsEncrypt via an ACME Autocert manager.
|
||||||
//
|
//
|
||||||
// It also starts a listener on the configured LetsEncrypt
|
// It also sets r.leSrv and starts a listener on the
|
||||||
// port to validate LE requests.
|
// configured LetsEncrypt port to validate LE requests.
|
||||||
func (r *Router) letsEncryptTLS() (func() error, error) {
|
func (r *Router) letsEncryptTLS() (func() error, error) {
|
||||||
acm := &autocert.Manager{
|
acm := &autocert.Manager{
|
||||||
Prompt: autocert.AcceptTOS,
|
Prompt: autocert.AcceptTOS,
|
||||||
|
@ -261,17 +285,18 @@ func (r *Router) letsEncryptTLS() (func() error, error) {
|
||||||
// Take our own copy of the HTTP server,
|
// Take our own copy of the HTTP server,
|
||||||
// and update it to serve LetsEncrypt
|
// and update it to serve LetsEncrypt
|
||||||
// requests via the autocert manager.
|
// requests via the autocert manager.
|
||||||
leSrv := (*r.srv) //nolint:govet
|
r.leSrv = new(http.Server) //nolint:gosec
|
||||||
leSrv.Handler = acm.HTTPHandler(fallback)
|
*r.leSrv = (*r.srv) //nolint:govet
|
||||||
leSrv.Addr = fmt.Sprintf("%s:%d",
|
r.leSrv.Handler = acm.HTTPHandler(fallback)
|
||||||
|
r.leSrv.Addr = fmt.Sprintf("%s:%d",
|
||||||
config.GetBindAddress(),
|
config.GetBindAddress(),
|
||||||
config.GetLetsEncryptPort(),
|
config.GetLetsEncryptPort(),
|
||||||
)
|
)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
// Start the LetsEncrypt autocert manager HTTP server.
|
// Start the LetsEncrypt autocert manager HTTP server.
|
||||||
log.Infof(nil, "letsencrypt listening on %s", leSrv.Addr)
|
log.Infof(nil, "letsencrypt listening on %s", r.leSrv.Addr)
|
||||||
if err := leSrv.ListenAndServe(); err != nil &&
|
if err := r.leSrv.ListenAndServe(); err != nil &&
|
||||||
err != http.ErrServerClosed {
|
err != http.ErrServerClosed {
|
||||||
log.Panicf(nil, "letsencrypt: listen: %v", err)
|
log.Panicf(nil, "letsencrypt: listen: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -177,7 +177,7 @@ func (p *hashtagParser) Parse(
|
||||||
// Ignore initial '#'.
|
// Ignore initial '#'.
|
||||||
continue
|
continue
|
||||||
|
|
||||||
case !isPlausiblyInHashtag(r) &&
|
case !isPermittedInHashtag(r) &&
|
||||||
!isHashtagBoundary(r):
|
!isHashtagBoundary(r):
|
||||||
// Weird non-boundary character
|
// Weird non-boundary character
|
||||||
// in the hashtag. Don't trust it.
|
// in the hashtag. Don't trust it.
|
||||||
|
|
|
@ -50,6 +50,8 @@
|
||||||
withInlineCode2Expected = "<p><code>Nobody tells you about the </code><del>SECRET CODE</del><code>, do they?</code></p>"
|
withInlineCode2Expected = "<p><code>Nobody tells you about the </code><del>SECRET CODE</del><code>, do they?</code></p>"
|
||||||
withHashtag = "# Title\n\nhere's a simple status that uses hashtag #Hashtag!"
|
withHashtag = "# Title\n\nhere's a simple status that uses hashtag #Hashtag!"
|
||||||
withHashtagExpected = "<h1>Title</h1><p>here's a simple status that uses hashtag <a href=\"http://localhost:8080/tags/hashtag\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>Hashtag</span></a>!</p>"
|
withHashtagExpected = "<h1>Title</h1><p>here's a simple status that uses hashtag <a href=\"http://localhost:8080/tags/hashtag\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>Hashtag</span></a>!</p>"
|
||||||
|
withTamilHashtag = "here's a simple status that uses a hashtag in Tamil #தமிழ்"
|
||||||
|
withTamilHashtagExpected = "<p>here's a simple status that uses a hashtag in Tamil <a href=\"http://localhost:8080/tags/%E0%AE%A4%E0%AE%AE%E0%AE%BF%E0%AE%B4%E0%AF%8D\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>தமிழ்</span></a></p>"
|
||||||
mdWithHTML = "# Title\n\nHere's a simple text in markdown.\n\nHere's a <a href=\"https://example.org\">link</a>.\n\nHere's an image: <img src=\"https://gts.superseriousbusiness.org/assets/logo.png\" alt=\"The GoToSocial sloth logo.\" width=\"500\" height=\"600\">"
|
mdWithHTML = "# Title\n\nHere's a simple text in markdown.\n\nHere's a <a href=\"https://example.org\">link</a>.\n\nHere's an image: <img src=\"https://gts.superseriousbusiness.org/assets/logo.png\" alt=\"The GoToSocial sloth logo.\" width=\"500\" height=\"600\">"
|
||||||
mdWithHTMLExpected = "<h1>Title</h1><p>Here's a simple text in markdown.</p><p>Here's a <a href=\"https://example.org\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">link</a>.</p><p>Here's an image:</p>"
|
mdWithHTMLExpected = "<h1>Title</h1><p>Here's a simple text in markdown.</p><p>Here's a <a href=\"https://example.org\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">link</a>.</p><p>Here's an image:</p>"
|
||||||
mdWithCheekyHTML = "# Title\n\nHere's a simple text in markdown.\n\nHere's a cheeky little script: <script>alert(ahhhh)</script>"
|
mdWithCheekyHTML = "# Title\n\nHere's a simple text in markdown.\n\nHere's a cheeky little script: <script>alert(ahhhh)</script>"
|
||||||
|
@ -121,6 +123,12 @@ func (suite *MarkdownTestSuite) TestParseWithHashtag() {
|
||||||
suite.Equal(withHashtagExpected, formatted.HTML)
|
suite.Equal(withHashtagExpected, formatted.HTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regressiom test for https://github.com/superseriousbusiness/gotosocial/issues/3618
|
||||||
|
func (suite *MarkdownTestSuite) TestParseWithTamilHashtag() {
|
||||||
|
formatted := suite.FromMarkdown(withTamilHashtag)
|
||||||
|
suite.Equal(withTamilHashtagExpected, formatted.HTML)
|
||||||
|
}
|
||||||
|
|
||||||
func (suite *MarkdownTestSuite) TestParseWithHTML() {
|
func (suite *MarkdownTestSuite) TestParseWithHTML() {
|
||||||
formatted := suite.FromMarkdown(mdWithHTML)
|
formatted := suite.FromMarkdown(mdWithHTML)
|
||||||
suite.Equal(mdWithHTMLExpected, formatted.HTML)
|
suite.Equal(mdWithHTMLExpected, formatted.HTML)
|
||||||
|
|
|
@ -50,17 +50,16 @@ func NormalizeHashtag(text string) (string, bool) {
|
||||||
|
|
||||||
// Validate normalized result.
|
// Validate normalized result.
|
||||||
var (
|
var (
|
||||||
notJustUnderscores = false
|
atLeastOneRequiredChar = false
|
||||||
onlyPermittedChars = true
|
onlyPermittedChars = true
|
||||||
lengthOK = true
|
lengthOK = true
|
||||||
)
|
)
|
||||||
|
|
||||||
for i, r := range normalized {
|
for i, r := range normalized {
|
||||||
if r != '_' {
|
if !isPermittedIfNotEntireHashtag(r) {
|
||||||
// This isn't an underscore,
|
// This isn't an underscore, mark, etc,
|
||||||
// so the whole hashtag isn't
|
// so the hashtag contains at least one
|
||||||
// just underscores.
|
atLeastOneRequiredChar = true
|
||||||
notJustUnderscores = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if i >= maximumHashtagLength {
|
if i >= maximumHashtagLength {
|
||||||
|
@ -74,5 +73,5 @@ func NormalizeHashtag(text string) (string, bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized, (lengthOK && onlyPermittedChars && notJustUnderscores)
|
return normalized, lengthOK && onlyPermittedChars && atLeastOneRequiredChar
|
||||||
}
|
}
|
||||||
|
|
|
@ -118,20 +118,20 @@ func (suite *PlainTestSuite) TestDeriveHashtagsOK() {
|
||||||
`
|
`
|
||||||
|
|
||||||
tags := suite.FromPlain(statusText).Tags
|
tags := suite.FromPlain(statusText).Tags
|
||||||
suite.Len(tags, 13)
|
if suite.Len(tags, 12) {
|
||||||
suite.Equal("testing123", tags[0].Name)
|
suite.Equal("testing123", tags[0].Name)
|
||||||
suite.Equal("also", tags[1].Name)
|
suite.Equal("also", tags[1].Name)
|
||||||
suite.Equal("thisshouldwork", tags[2].Name)
|
suite.Equal("thisshouldwork", tags[2].Name)
|
||||||
suite.Equal("dupe", tags[3].Name)
|
suite.Equal("dupe", tags[3].Name)
|
||||||
suite.Equal("ThisShouldAlsoWork", tags[4].Name)
|
suite.Equal("ThisShouldAlsoWork", tags[4].Name)
|
||||||
suite.Equal("this_should_not_be_split", tags[5].Name)
|
suite.Equal("this_should_not_be_split", tags[5].Name)
|
||||||
suite.Equal("111111", tags[6].Name)
|
suite.Equal("alimentación", tags[6].Name)
|
||||||
suite.Equal("alimentación", tags[7].Name)
|
suite.Equal("saúde", tags[7].Name)
|
||||||
suite.Equal("saúde", tags[8].Name)
|
suite.Equal("lävistää", tags[8].Name)
|
||||||
suite.Equal("lävistää", tags[9].Name)
|
suite.Equal("ö", tags[9].Name)
|
||||||
suite.Equal("ö", tags[10].Name)
|
suite.Equal("네", tags[10].Name)
|
||||||
suite.Equal("네", tags[11].Name)
|
suite.Equal("ThisOneIsThirteyCharactersLong", tags[11].Name)
|
||||||
suite.Equal("ThisOneIsThirteyCharactersLong", tags[12].Name)
|
}
|
||||||
|
|
||||||
statusText = `#올빼미 hej`
|
statusText = `#올빼미 hej`
|
||||||
tags = suite.FromPlain(statusText).Tags
|
tags = suite.FromPlain(statusText).Tags
|
||||||
|
@ -170,8 +170,17 @@ func (suite *PlainTestSuite) TestDeriveMultiple() {
|
||||||
func (suite *PlainTestSuite) TestZalgoHashtag() {
|
func (suite *PlainTestSuite) TestZalgoHashtag() {
|
||||||
statusText := `yo who else loves #praying to #z̸͉̅a̸͚͋l̵͈̊g̸̫͌ỏ̷̪?`
|
statusText := `yo who else loves #praying to #z̸͉̅a̸͚͋l̵͈̊g̸̫͌ỏ̷̪?`
|
||||||
f := suite.FromPlain(statusText)
|
f := suite.FromPlain(statusText)
|
||||||
suite.Len(f.Tags, 1)
|
if suite.Len(f.Tags, 2) {
|
||||||
suite.Equal("praying", f.Tags[0].Name)
|
suite.Equal("praying", f.Tags[0].Name)
|
||||||
|
// NFC doesn't do much for Zalgo text, but it's difficult to strip marks without affecting non-Latin text.
|
||||||
|
suite.Equal("z̸͉̅a̸͚͋l̵͈̊g̸̫͌ỏ̷̪", f.Tags[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *PlainTestSuite) TestNumbersAreNotHashtags() {
|
||||||
|
statusText := `yo who else thinks #19_98 is #1?`
|
||||||
|
f := suite.FromPlain(statusText)
|
||||||
|
suite.Len(f.Tags, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPlainTestSuite(t *testing.T) {
|
func TestPlainTestSuite(t *testing.T) {
|
||||||
|
|
|
@ -19,19 +19,14 @@
|
||||||
|
|
||||||
import "unicode"
|
import "unicode"
|
||||||
|
|
||||||
func isPlausiblyInHashtag(r rune) bool {
|
func isPermittedInHashtag(r rune) bool {
|
||||||
// Marks are allowed during parsing
|
return unicode.IsLetter(r) || isPermittedIfNotEntireHashtag(r)
|
||||||
// prior to normalization, but not after,
|
|
||||||
// since they may be combined into letters
|
|
||||||
// during normalization.
|
|
||||||
return unicode.IsMark(r) ||
|
|
||||||
isPermittedInHashtag(r)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPermittedInHashtag(r rune) bool {
|
// isPermittedIfNotEntireHashtag is true for characters that may be in a hashtag
|
||||||
return unicode.IsLetter(r) ||
|
// but are not allowed to be the only characters making up the hashtag.
|
||||||
unicode.IsNumber(r) ||
|
func isPermittedIfNotEntireHashtag(r rune) bool {
|
||||||
r == '_'
|
return unicode.IsNumber(r) || unicode.IsMark(r) || r == '_'
|
||||||
}
|
}
|
||||||
|
|
||||||
// isHashtagBoundary returns true if rune r
|
// isHashtagBoundary returns true if rune r
|
||||||
|
|
|
@ -18,10 +18,17 @@
|
||||||
package typeutils
|
package typeutils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
crand "crypto/rand"
|
||||||
|
"math/big"
|
||||||
|
"math/rand"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/filter/interaction"
|
"github.com/superseriousbusiness/gotosocial/internal/filter/interaction"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
"github.com/superseriousbusiness/gotosocial/internal/filter/visibility"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -31,6 +38,7 @@ type Converter struct {
|
||||||
randAvatars sync.Map
|
randAvatars sync.Map
|
||||||
visFilter *visibility.Filter
|
visFilter *visibility.Filter
|
||||||
intFilter *interaction.Filter
|
intFilter *interaction.Filter
|
||||||
|
randStats atomic.Pointer[apimodel.RandomStats]
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConverter(state *state.State) *Converter {
|
func NewConverter(state *state.State) *Converter {
|
||||||
|
@ -41,3 +49,53 @@ func NewConverter(state *state.State) *Converter {
|
||||||
intFilter: interaction.NewFilter(state),
|
intFilter: interaction.NewFilter(state),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RandomStats returns or generates
|
||||||
|
// and returns random instance stats.
|
||||||
|
func (c *Converter) RandomStats() apimodel.RandomStats {
|
||||||
|
now := time.Now()
|
||||||
|
stats := c.randStats.Load()
|
||||||
|
if stats != nil && time.Since(stats.Generated) < time.Hour {
|
||||||
|
// Random stats are still
|
||||||
|
// fresh (less than 1hr old),
|
||||||
|
// so return them as-is.
|
||||||
|
return *stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new random stats.
|
||||||
|
newStats := genRandStats()
|
||||||
|
newStats.Generated = now
|
||||||
|
c.randStats.Store(&newStats)
|
||||||
|
return newStats
|
||||||
|
}
|
||||||
|
|
||||||
|
func genRandStats() apimodel.RandomStats {
|
||||||
|
const (
|
||||||
|
statusesMax = 10000000
|
||||||
|
usersMax = 1000000
|
||||||
|
)
|
||||||
|
|
||||||
|
statusesB, err := crand.Int(crand.Reader, big.NewInt(statusesMax))
|
||||||
|
if err != nil {
|
||||||
|
// Only errs if something is buggered with the OS.
|
||||||
|
log.Panicf(nil, "error randomly generating statuses count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalUsersB, err := crand.Int(crand.Reader, big.NewInt(usersMax))
|
||||||
|
if err != nil {
|
||||||
|
// Only errs if something is buggered with the OS.
|
||||||
|
log.Panicf(nil, "error randomly generating users count: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly users should only ever
|
||||||
|
// be <= 100% of total users.
|
||||||
|
totalUsers := totalUsersB.Int64()
|
||||||
|
activeRatio := rand.Float64() //nolint
|
||||||
|
mau := int64(float64(totalUsers) * activeRatio)
|
||||||
|
|
||||||
|
return apimodel.RandomStats{
|
||||||
|
Statuses: statusesB.Int64(),
|
||||||
|
TotalUsers: totalUsers,
|
||||||
|
MonthlyActiveUsers: mau,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -2021,7 +2021,19 @@ func (c *Converter) InteractionReqToASAccept(
|
||||||
|
|
||||||
objectIRI, err := url.Parse(req.InteractionURI)
|
objectIRI, err := url.Parse(req.InteractionURI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, gtserror.Newf("invalid target uri: %w", err)
|
return nil, gtserror.Newf("invalid object uri: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status == nil {
|
||||||
|
req.Status, err = c.state.DB.GetStatusByID(ctx, req.StatusID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gtserror.Newf("db error getting interaction req target status: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetIRI, err := url.Parse(req.Status.URI)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gtserror.Newf("invalid interaction req target status uri: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
toIRI, err := url.Parse(req.InteractingAccount.URI)
|
toIRI, err := url.Parse(req.InteractingAccount.URI)
|
||||||
|
@ -2040,6 +2052,10 @@ func (c *Converter) InteractionReqToASAccept(
|
||||||
// Object is the interaction URI.
|
// Object is the interaction URI.
|
||||||
ap.AppendObjectIRIs(accept, objectIRI)
|
ap.AppendObjectIRIs(accept, objectIRI)
|
||||||
|
|
||||||
|
// Target is the URI of the
|
||||||
|
// status being interacted with.
|
||||||
|
ap.AppendTargetIRIs(accept, targetIRI)
|
||||||
|
|
||||||
// Address to the owner
|
// Address to the owner
|
||||||
// of interaction URI.
|
// of interaction URI.
|
||||||
ap.AppendTo(accept, toIRI)
|
ap.AppendTo(accept, toIRI)
|
||||||
|
@ -2101,7 +2117,19 @@ func (c *Converter) InteractionReqToASReject(
|
||||||
|
|
||||||
objectIRI, err := url.Parse(req.InteractionURI)
|
objectIRI, err := url.Parse(req.InteractionURI)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, gtserror.Newf("invalid target uri: %w", err)
|
return nil, gtserror.Newf("invalid object uri: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Status == nil {
|
||||||
|
req.Status, err = c.state.DB.GetStatusByID(ctx, req.StatusID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gtserror.Newf("db error getting interaction req target status: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetIRI, err := url.Parse(req.Status.URI)
|
||||||
|
if err != nil {
|
||||||
|
return nil, gtserror.Newf("invalid interaction req target status uri: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
toIRI, err := url.Parse(req.InteractingAccount.URI)
|
toIRI, err := url.Parse(req.InteractingAccount.URI)
|
||||||
|
@ -2120,6 +2148,10 @@ func (c *Converter) InteractionReqToASReject(
|
||||||
// Object is the interaction URI.
|
// Object is the interaction URI.
|
||||||
ap.AppendObjectIRIs(reject, objectIRI)
|
ap.AppendObjectIRIs(reject, objectIRI)
|
||||||
|
|
||||||
|
// Target is the URI of the
|
||||||
|
// status being interacted with.
|
||||||
|
ap.AppendTargetIRIs(reject, targetIRI)
|
||||||
|
|
||||||
// Address to the owner
|
// Address to the owner
|
||||||
// of interaction URI.
|
// of interaction URI.
|
||||||
ap.AppendTo(reject, toIRI)
|
ap.AppendTo(reject, toIRI)
|
||||||
|
|
|
@ -1235,6 +1235,8 @@ func (suite *InternalToASTestSuite) TestInteractionReqToASAcceptAnnounce() {
|
||||||
req := >smodel.InteractionRequest{
|
req := >smodel.InteractionRequest{
|
||||||
ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE",
|
ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE",
|
||||||
CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"),
|
CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"),
|
||||||
|
StatusID: "01JJYCVKCXB9JTQD1XW2KB8MT3",
|
||||||
|
Status: >smodel.Status{URI: "http://localhost:8080/users/the_mighty_zork/statuses/01JJYCVKCXB9JTQD1XW2KB8MT3"},
|
||||||
TargetAccountID: acceptingAccount.ID,
|
TargetAccountID: acceptingAccount.ID,
|
||||||
TargetAccount: acceptingAccount,
|
TargetAccount: acceptingAccount,
|
||||||
InteractingAccountID: interactingAccount.ID,
|
InteractingAccountID: interactingAccount.ID,
|
||||||
|
@ -1272,6 +1274,7 @@ func (suite *InternalToASTestSuite) TestInteractionReqToASAcceptAnnounce() {
|
||||||
],
|
],
|
||||||
"id": "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE",
|
"id": "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE",
|
||||||
"object": "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K",
|
"object": "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K",
|
||||||
|
"target": "http://localhost:8080/users/the_mighty_zork/statuses/01JJYCVKCXB9JTQD1XW2KB8MT3",
|
||||||
"to": "http://fossbros-anonymous.io/users/foss_satan",
|
"to": "http://fossbros-anonymous.io/users/foss_satan",
|
||||||
"type": "Accept"
|
"type": "Accept"
|
||||||
}`, string(b))
|
}`, string(b))
|
||||||
|
@ -1284,6 +1287,8 @@ func (suite *InternalToASTestSuite) TestInteractionReqToASAcceptLike() {
|
||||||
req := >smodel.InteractionRequest{
|
req := >smodel.InteractionRequest{
|
||||||
ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE",
|
ID: "01J1AKMZ8JE5NW0ZSFTRC1JJNE",
|
||||||
CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"),
|
CreatedAt: testrig.TimeMustParse("2022-06-09T13:12:00Z"),
|
||||||
|
StatusID: "01JJYCVKCXB9JTQD1XW2KB8MT3",
|
||||||
|
Status: >smodel.Status{URI: "http://localhost:8080/users/the_mighty_zork/statuses/01JJYCVKCXB9JTQD1XW2KB8MT3"},
|
||||||
TargetAccountID: acceptingAccount.ID,
|
TargetAccountID: acceptingAccount.ID,
|
||||||
TargetAccount: acceptingAccount,
|
TargetAccount: acceptingAccount,
|
||||||
InteractingAccountID: interactingAccount.ID,
|
InteractingAccountID: interactingAccount.ID,
|
||||||
|
@ -1317,6 +1322,7 @@ func (suite *InternalToASTestSuite) TestInteractionReqToASAcceptLike() {
|
||||||
"actor": "http://localhost:8080/users/the_mighty_zork",
|
"actor": "http://localhost:8080/users/the_mighty_zork",
|
||||||
"id": "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE",
|
"id": "http://localhost:8080/users/the_mighty_zork/accepts/01J1AKMZ8JE5NW0ZSFTRC1JJNE",
|
||||||
"object": "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K",
|
"object": "https://fossbros-anonymous.io/users/foss_satan/statuses/01J1AKRRHQ6MDDQHV0TP716T2K",
|
||||||
|
"target": "http://localhost:8080/users/the_mighty_zork/statuses/01JJYCVKCXB9JTQD1XW2KB8MT3",
|
||||||
"to": "http://fossbros-anonymous.io/users/foss_satan",
|
"to": "http://fossbros-anonymous.io/users/foss_satan",
|
||||||
"type": "Accept"
|
"type": "Accept"
|
||||||
}`, string(b))
|
}`, string(b))
|
||||||
|
|
|
@ -943,8 +943,9 @@ func (c *Converter) statusToAPIFilterResults(
|
||||||
// Both mutes and filters can expire.
|
// Both mutes and filters can expire.
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
// If the requesting account mutes the account that created this status, hide the status.
|
// If requesting account mutes the author (taking boosts into account), hide the status.
|
||||||
if mutes.Matches(s.AccountID, filterContext, now) {
|
if (s.BoostOfAccountID != "" && mutes.Matches(s.BoostOfAccountID, filterContext, now)) ||
|
||||||
|
mutes.Matches(s.AccountID, filterContext, now) {
|
||||||
return nil, statusfilter.ErrHideStatus
|
return nil, statusfilter.ErrHideStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1744,6 +1745,12 @@ func (c *Converter) InstanceToAPIV1Instance(ctx context.Context, i *gtsmodel.Ins
|
||||||
stats["domain_count"] = util.Ptr(domainCount)
|
stats["domain_count"] = util.Ptr(domainCount)
|
||||||
instance.Stats = stats
|
instance.Stats = stats
|
||||||
|
|
||||||
|
if config.GetInstanceStatsRandomize() {
|
||||||
|
// Whack some random stats on the instance
|
||||||
|
// to be injected by API handlers.
|
||||||
|
instance.RandomStats = c.RandomStats()
|
||||||
|
}
|
||||||
|
|
||||||
// thumbnail
|
// thumbnail
|
||||||
iAccount, err := c.state.DB.GetInstanceAccount(ctx, "")
|
iAccount, err := c.state.DB.GetInstanceAccount(ctx, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -1820,6 +1827,12 @@ func (c *Converter) InstanceToAPIV2Instance(ctx context.Context, i *gtsmodel.Ins
|
||||||
instance.Debug = util.Ptr(true)
|
instance.Debug = util.Ptr(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.GetInstanceStatsRandomize() {
|
||||||
|
// Whack some random stats on the instance
|
||||||
|
// to be injected by API handlers.
|
||||||
|
instance.RandomStats = c.RandomStats()
|
||||||
|
}
|
||||||
|
|
||||||
// thumbnail
|
// thumbnail
|
||||||
thumbnail := apimodel.InstanceV2Thumbnail{}
|
thumbnail := apimodel.InstanceV2Thumbnail{}
|
||||||
|
|
||||||
|
@ -2641,28 +2654,36 @@ func (c *Converter) FilterStatusToAPIFilterStatus(ctx context.Context, filterSta
|
||||||
func (c *Converter) convertEmojisToAPIEmojis(ctx context.Context, emojis []*gtsmodel.Emoji, emojiIDs []string) ([]apimodel.Emoji, error) {
|
func (c *Converter) convertEmojisToAPIEmojis(ctx context.Context, emojis []*gtsmodel.Emoji, emojiIDs []string) ([]apimodel.Emoji, error) {
|
||||||
var errs gtserror.MultiError
|
var errs gtserror.MultiError
|
||||||
|
|
||||||
if len(emojis) == 0 && len(emojiIDs) > 0 {
|
|
||||||
// GTS model attachments were not populated
|
// GTS model attachments were not populated
|
||||||
|
if len(emojis) == 0 && len(emojiIDs) > 0 {
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// Fetch GTS models for emoji IDs
|
// Fetch GTS models for emoji IDs
|
||||||
emojis, err = c.state.DB.GetEmojisByIDs(ctx, emojiIDs)
|
emojis, err = c.state.DB.GetEmojisByIDs(ctx, emojiIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs.Appendf("error fetching emojis from database: %w", err)
|
return nil, gtserror.Newf("db error fetching emojis: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preallocate expected frontend slice
|
// Preallocate expected frontend slice of emojis.
|
||||||
apiEmojis := make([]apimodel.Emoji, 0, len(emojis))
|
apiEmojis := make([]apimodel.Emoji, 0, len(emojis))
|
||||||
|
|
||||||
// Convert GTS models to frontend models
|
|
||||||
for _, emoji := range emojis {
|
for _, emoji := range emojis {
|
||||||
|
|
||||||
|
// Skip adding emojis that are
|
||||||
|
// uncached, the empty URLs can
|
||||||
|
// cause issues with some clients.
|
||||||
|
if !*emoji.Cached {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert each to a frontend API model emoji.
|
||||||
apiEmoji, err := c.EmojiToAPIEmoji(ctx, emoji)
|
apiEmoji, err := c.EmojiToAPIEmoji(ctx, emoji)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs.Appendf("error converting emoji %s to api emoji: %w", emoji.ID, err)
|
errs.Appendf("error converting emoji %s to api emoji: %w", emoji.ID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append converted emoji to return slice.
|
||||||
apiEmojis = append(apiEmojis, apiEmoji)
|
apiEmojis = append(apiEmojis, apiEmoji)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1161,6 +1161,7 @@ func (suite *InternalToFrontendTestSuite) TestHashtagAnywhereFilteredBoostToFron
|
||||||
func (suite *InternalToFrontendTestSuite) TestMutedStatusToFrontend() {
|
func (suite *InternalToFrontendTestSuite) TestMutedStatusToFrontend() {
|
||||||
testStatus := suite.testStatuses["admin_account_status_1"]
|
testStatus := suite.testStatuses["admin_account_status_1"]
|
||||||
requestingAccount := suite.testAccounts["local_account_1"]
|
requestingAccount := suite.testAccounts["local_account_1"]
|
||||||
|
|
||||||
mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{
|
mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{
|
||||||
{
|
{
|
||||||
AccountID: requestingAccount.ID,
|
AccountID: requestingAccount.ID,
|
||||||
|
@ -1168,6 +1169,7 @@ func (suite *InternalToFrontendTestSuite) TestMutedStatusToFrontend() {
|
||||||
Notifications: util.Ptr(false),
|
Notifications: util.Ptr(false),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err := suite.typeconverter.StatusToAPIStatus(
|
_, err := suite.typeconverter.StatusToAPIStatus(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
testStatus,
|
testStatus,
|
||||||
|
@ -1186,6 +1188,7 @@ func (suite *InternalToFrontendTestSuite) TestMutedReplyStatusToFrontend() {
|
||||||
testStatus.InReplyToID = suite.testStatuses["local_account_2_status_1"].ID
|
testStatus.InReplyToID = suite.testStatuses["local_account_2_status_1"].ID
|
||||||
testStatus.InReplyToAccountID = mutedAccount.ID
|
testStatus.InReplyToAccountID = mutedAccount.ID
|
||||||
requestingAccount := suite.testAccounts["local_account_1"]
|
requestingAccount := suite.testAccounts["local_account_1"]
|
||||||
|
|
||||||
mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{
|
mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{
|
||||||
{
|
{
|
||||||
AccountID: requestingAccount.ID,
|
AccountID: requestingAccount.ID,
|
||||||
|
@ -1193,11 +1196,46 @@ func (suite *InternalToFrontendTestSuite) TestMutedReplyStatusToFrontend() {
|
||||||
Notifications: util.Ptr(false),
|
Notifications: util.Ptr(false),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Populate status so the converter has the account objects it needs for muting.
|
// Populate status so the converter has the account objects it needs for muting.
|
||||||
err := suite.db.PopulateStatus(context.Background(), testStatus)
|
err := suite.db.PopulateStatus(context.Background(), testStatus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
suite.FailNow(err.Error())
|
suite.FailNow(err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert the status to API format, which should fail.
|
||||||
|
_, err = suite.typeconverter.StatusToAPIStatus(
|
||||||
|
context.Background(),
|
||||||
|
testStatus,
|
||||||
|
requestingAccount,
|
||||||
|
statusfilter.FilterContextHome,
|
||||||
|
nil,
|
||||||
|
mutes,
|
||||||
|
)
|
||||||
|
suite.ErrorIs(err, statusfilter.ErrHideStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (suite *InternalToFrontendTestSuite) TestMutedBoostStatusToFrontend() {
|
||||||
|
mutedAccount := suite.testAccounts["local_account_2"]
|
||||||
|
testStatus := suite.testStatuses["admin_account_status_1"]
|
||||||
|
testStatus.BoostOfID = suite.testStatuses["local_account_2_status_1"].ID
|
||||||
|
testStatus.BoostOfAccountID = mutedAccount.ID
|
||||||
|
requestingAccount := suite.testAccounts["local_account_1"]
|
||||||
|
|
||||||
|
mutes := usermute.NewCompiledUserMuteList([]*gtsmodel.UserMute{
|
||||||
|
{
|
||||||
|
AccountID: requestingAccount.ID,
|
||||||
|
TargetAccountID: mutedAccount.ID,
|
||||||
|
Notifications: util.Ptr(false),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Populate status so the converter has the account objects it needs for muting.
|
||||||
|
err := suite.db.PopulateStatus(context.Background(), testStatus)
|
||||||
|
if err != nil {
|
||||||
|
suite.FailNow(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
// Convert the status to API format, which should fail.
|
// Convert the status to API format, which should fail.
|
||||||
_, err = suite.typeconverter.StatusToAPIStatus(
|
_, err = suite.typeconverter.StatusToAPIStatus(
|
||||||
context.Background(),
|
context.Background(),
|
||||||
|
@ -1240,7 +1278,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments
|
||||||
"muted": false,
|
"muted": false,
|
||||||
"bookmarked": false,
|
"bookmarked": false,
|
||||||
"pinned": false,
|
"pinned": false,
|
||||||
"content": "\u003cp\u003ehi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e here's some media for ya\u003c/p\u003e\u003chr\u003e\u003cp\u003e\u003ci lang=\"en\"\u003eℹ️ Note from localhost:8080: 2 attachments in this status were not downloaded. Treat the following external links with care:\u003c/i\u003e\u003c/p\u003e\u003cul\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE7ZGJYTSYMXF927GF9353KR.svg\u003c/a\u003e [SVG line art of a sloth, public domain]\u003c/li\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE892Y8ZS68TQCNPX7J888P3.mp3\u003c/a\u003e [Jolly salsa song, public domain.]\u003c/li\u003e\u003c/ul\u003e",
|
"content": "\u003cp\u003ehi \u003cspan class=\"h-card\"\u003e\u003ca href=\"http://localhost:8080/@admin\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e@\u003cspan\u003eadmin\u003c/span\u003e\u003c/a\u003e\u003c/span\u003e here's some media for ya\u003c/p\u003e\u003cdiv class=\"gts-system-message gts-placeholder-attachments\"\u003e\u003chr\u003e\u003cp\u003e\u003ci lang=\"en\"\u003eℹ️ Note from localhost:8080: 2 attachments in this status were not downloaded. Treat the following external links with care:\u003c/i\u003e\u003c/p\u003e\u003cul\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE7ZGJYTSYMXF927GF9353KR.svg\u003c/a\u003e [SVG line art of a sloth, public domain]\u003c/li\u003e\u003cli\u003e\u003ca href=\"http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3\" rel=\"nofollow noreferrer noopener\" target=\"_blank\"\u003e01HE892Y8ZS68TQCNPX7J888P3.mp3\u003c/a\u003e [Jolly salsa song, public domain.]\u003c/li\u003e\u003c/ul\u003e\u003c/div\u003e",
|
||||||
"reblog": null,
|
"reblog": null,
|
||||||
"account": {
|
"account": {
|
||||||
"id": "01FHMQX3GAABWSM0S2VZEC2SWC",
|
"id": "01FHMQX3GAABWSM0S2VZEC2SWC",
|
||||||
|
@ -1790,7 +1828,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval()
|
||||||
"muted": false,
|
"muted": false,
|
||||||
"bookmarked": false,
|
"bookmarked": false,
|
||||||
"pinned": false,
|
"pinned": false,
|
||||||
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p><hr><p><i lang=\"en\">ℹ️ 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: <a href=\"http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR\" rel=\"noreferrer noopener nofollow\" target=\"_blank\">http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR</a>.</i></p>",
|
"content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p><div class=\"gts-system-message gts-pending-reply\"><hr><p><i lang=\"en\">ℹ️ 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: <a href=\"http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR\" rel=\"noreferrer noopener nofollow\" target=\"_blank\">http://localhost:8080/settings/user/interaction_requests/01J5QVXCCEATJYSXM9H6MZT4JR</a>.</i></p></div>",
|
||||||
"reblog": null,
|
"reblog": null,
|
||||||
"application": {
|
"application": {
|
||||||
"name": "superseriousbusiness",
|
"name": "superseriousbusiness",
|
||||||
|
|
|
@ -128,12 +128,14 @@ func misskeyReportInlineURLs(content string) []*url.URL {
|
||||||
//
|
//
|
||||||
// Example:
|
// Example:
|
||||||
//
|
//
|
||||||
|
// <div class="gts-system-message gts-placeholder-attachments">
|
||||||
// <hr>
|
// <hr>
|
||||||
// <p><i lang="en">ℹ️ Note from your.instance.com: 2 attachment(s) in this status were not downloaded. Treat the following external link(s) with care:</i></p>
|
// <p><i lang="en">ℹ️ Note from your.instance.com: 2 attachment(s) in this status were not downloaded. Treat the following external link(s) with care:</i></p>
|
||||||
// <ul>
|
// <ul>
|
||||||
// <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg" rel="nofollow noreferrer noopener" target="_blank">01HE7ZGJYTSYMXF927GF9353KR.svg</a> [SVG line art of a sloth, public domain]</li>
|
// <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg" rel="nofollow noreferrer noopener" target="_blank">01HE7ZGJYTSYMXF927GF9353KR.svg</a> [SVG line art of a sloth, public domain]</li>
|
||||||
// <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3" rel="nofollow noreferrer noopener" target="_blank">01HE892Y8ZS68TQCNPX7J888P3.mp3</a> [Jolly salsa song, public domain.]</li>
|
// <li><a href="http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3" rel="nofollow noreferrer noopener" target="_blank">01HE892Y8ZS68TQCNPX7J888P3.mp3</a> [Jolly salsa song, public domain.]</li>
|
||||||
// </ul>
|
// </ul>
|
||||||
|
// </div>
|
||||||
func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Attachment) {
|
func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Attachment) {
|
||||||
|
|
||||||
// Extract non-locally stored attachments into a
|
// Extract non-locally stored attachments into a
|
||||||
|
@ -187,7 +189,7 @@ func placeholderAttachments(arr []*apimodel.Attachment) (string, []*apimodel.Att
|
||||||
}
|
}
|
||||||
note.WriteString(`</ul>`)
|
note.WriteString(`</ul>`)
|
||||||
|
|
||||||
return text.SanitizeToHTML(note.String()), arr
|
return systemMessage("gts-placeholder-attachments", note.String()), arr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Converter) pendingReplyNote(
|
func (c *Converter) pendingReplyNote(
|
||||||
|
@ -228,7 +230,27 @@ func (c *Converter) pendingReplyNote(
|
||||||
note.WriteString(`</a>.`)
|
note.WriteString(`</a>.`)
|
||||||
note.WriteString(`</i></p>`)
|
note.WriteString(`</i></p>`)
|
||||||
|
|
||||||
return text.SanitizeToHTML(note.String()), nil
|
return systemMessage("gts-pending-reply", note.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemMessage wraps a note with a div with semantic classes that aren't allowed through the sanitizer,
|
||||||
|
// but may be emitted to the client as an addition to the status's actual content.
|
||||||
|
// Clients may want to display these specially or suppress them in favor of their own UI.
|
||||||
|
//
|
||||||
|
// messageClass must be valid inside an HTML attribute and should be one or more classes starting with `gts-`.
|
||||||
|
func systemMessage(
|
||||||
|
messageClass string,
|
||||||
|
unsanitizedNoteHTML string,
|
||||||
|
) string {
|
||||||
|
var wrappedNote strings.Builder
|
||||||
|
|
||||||
|
wrappedNote.WriteString(`<div class="gts-system-message `)
|
||||||
|
wrappedNote.WriteString(messageClass)
|
||||||
|
wrappedNote.WriteString(`">`)
|
||||||
|
wrappedNote.WriteString(text.SanitizeToHTML(unsanitizedNoteHTML))
|
||||||
|
wrappedNote.WriteString(`</div>`)
|
||||||
|
|
||||||
|
return wrappedNote.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentToContentLanguage tries to
|
// ContentToContentLanguage tries to
|
||||||
|
|
|
@ -21,10 +21,13 @@
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
type fileSystem struct {
|
type fileSystem struct {
|
||||||
|
@ -53,7 +56,11 @@ func (fs fileSystem) Open(path string) (http.File, error) {
|
||||||
// getAssetFileInfo tries to fetch the ETag for the given filePath from the module's
|
// getAssetFileInfo tries to fetch the ETag for the given filePath from the module's
|
||||||
// assetsETagCache. If it can't be found there, it uses the provided http.FileSystem
|
// assetsETagCache. If it can't be found there, it uses the provided http.FileSystem
|
||||||
// to generate a new ETag to go in the cache, which it then returns.
|
// to generate a new ETag to go in the cache, which it then returns.
|
||||||
func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, error) {
|
func getAssetETag(
|
||||||
|
wet withETagCache,
|
||||||
|
filePath string,
|
||||||
|
fs http.FileSystem,
|
||||||
|
) (string, error) {
|
||||||
file, err := fs.Open(filePath)
|
file, err := fs.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("error opening %s: %s", filePath, err)
|
return "", fmt.Errorf("error opening %s: %s", filePath, err)
|
||||||
|
@ -67,7 +74,8 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro
|
||||||
|
|
||||||
fileLastModified := fileInfo.ModTime()
|
fileLastModified := fileInfo.ModTime()
|
||||||
|
|
||||||
if cachedETag, ok := m.eTagCache.Get(filePath); ok && !fileLastModified.After(cachedETag.lastModified) {
|
cache := wet.ETagCache()
|
||||||
|
if cachedETag, ok := cache.Get(filePath); ok && !fileLastModified.After(cachedETag.lastModified) {
|
||||||
// only return our cached etag if the file wasn't
|
// only return our cached etag if the file wasn't
|
||||||
// modified since last time, otherwise generate a
|
// modified since last time, otherwise generate a
|
||||||
// new one; eat fresh!
|
// new one; eat fresh!
|
||||||
|
@ -80,7 +88,7 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro
|
||||||
}
|
}
|
||||||
|
|
||||||
// put new entry in cache before we return
|
// put new entry in cache before we return
|
||||||
m.eTagCache.Set(filePath, eTagCacheEntry{
|
cache.Set(filePath, eTagCacheEntry{
|
||||||
eTag: eTag,
|
eTag: eTag,
|
||||||
lastModified: fileLastModified,
|
lastModified: fileLastModified,
|
||||||
})
|
})
|
||||||
|
@ -99,7 +107,10 @@ func (m *Module) getAssetETag(filePath string, fs http.FileSystem) (string, erro
|
||||||
//
|
//
|
||||||
// todo: move this middleware out of the 'web' package and into the 'middleware'
|
// todo: move this middleware out of the 'web' package and into the 'middleware'
|
||||||
// package along with the other middlewares
|
// package along with the other middlewares
|
||||||
func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFunc {
|
func assetsCacheControlMiddleware(
|
||||||
|
wet withETagCache,
|
||||||
|
fs http.FileSystem,
|
||||||
|
) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Acquire context from gin request.
|
// Acquire context from gin request.
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
@ -118,7 +129,7 @@ func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFun
|
||||||
assetFilePath := strings.TrimPrefix(path.Clean(upath), assetsPathPrefix)
|
assetFilePath := strings.TrimPrefix(path.Clean(upath), assetsPathPrefix)
|
||||||
|
|
||||||
// either fetch etag from ttlcache or generate it
|
// either fetch etag from ttlcache or generate it
|
||||||
eTag, err := m.getAssetETag(assetFilePath, fs)
|
eTag, err := getAssetETag(wet, assetFilePath, fs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf(ctx, "error getting ETag for %s: %s", assetFilePath, err)
|
log.Errorf(ctx, "error getting ETag for %s: %s", assetFilePath, err)
|
||||||
return
|
return
|
||||||
|
@ -137,3 +148,23 @@ func (m *Module) assetsCacheControlMiddleware(fs http.FileSystem) gin.HandlerFun
|
||||||
// else let the rest of the request be processed normally
|
// else let the rest of the request be processed normally
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// routeAssets attaches *just* the
|
||||||
|
// assets filesystem to the given router.
|
||||||
|
func routeAssets(
|
||||||
|
wet withETagCache,
|
||||||
|
r *router.Router,
|
||||||
|
mi ...gin.HandlerFunc,
|
||||||
|
) {
|
||||||
|
// Group all static files from assets dir at /assets,
|
||||||
|
// so that they can use the same cache control middleware.
|
||||||
|
webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir())
|
||||||
|
if err != nil {
|
||||||
|
log.Panicf(nil, "error getting absolute path of assets dir: %s", err)
|
||||||
|
}
|
||||||
|
fs := fileSystem{http.Dir(webAssetsAbsFilePath)}
|
||||||
|
assetsGroup := r.AttachGroup(assetsPathPrefix)
|
||||||
|
assetsGroup.Use(assetsCacheControlMiddleware(wet, fs))
|
||||||
|
assetsGroup.Use(mi...)
|
||||||
|
assetsGroup.StaticFS("/", fs)
|
||||||
|
}
|
||||||
|
|
|
@ -29,6 +29,10 @@
|
||||||
"codeberg.org/gruf/go-cache/v3"
|
"codeberg.org/gruf/go-cache/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type withETagCache interface {
|
||||||
|
ETagCache() cache.Cache[string, eTagCacheEntry]
|
||||||
|
}
|
||||||
|
|
||||||
func newETagCache() cache.TTLCache[string, eTagCacheEntry] {
|
func newETagCache() cache.TTLCache[string, eTagCacheEntry] {
|
||||||
eTagCache := cache.NewTTL[string, eTagCacheEntry](0, 1000, 0)
|
eTagCache := cache.NewTTL[string, eTagCacheEntry](0, 1000, 0)
|
||||||
eTagCache.SetTTL(time.Hour, false)
|
eTagCache.SetTTL(time.Hour, false)
|
||||||
|
|
70
internal/web/maintenance.go
Normal file
70
internal/web/maintenance.go
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
// GoToSocial
|
||||||
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"codeberg.org/gruf/go-cache/v3"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/api/health"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||||
|
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MaintenanceModule struct {
|
||||||
|
eTagCache cache.Cache[string, eTagCacheEntry]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMaintenance returns a module that routes only
|
||||||
|
// static assets, and returns a code 503 maintenance
|
||||||
|
// message template to all other requests.
|
||||||
|
func NewMaintenance() *MaintenanceModule {
|
||||||
|
return &MaintenanceModule{
|
||||||
|
eTagCache: newETagCache(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ETagCache implements withETagCache.
|
||||||
|
func (m *MaintenanceModule) ETagCache() cache.Cache[string, eTagCacheEntry] {
|
||||||
|
return m.eTagCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MaintenanceModule) Route(r *router.Router, mi ...gin.HandlerFunc) {
|
||||||
|
// Route static assets.
|
||||||
|
routeAssets(m, r, mi...)
|
||||||
|
|
||||||
|
// Serve OK in response to live
|
||||||
|
// requests, but not ready requests.
|
||||||
|
liveHandler := func(c *gin.Context) {
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
r.AttachHandler(http.MethodGet, health.LivePath, liveHandler)
|
||||||
|
r.AttachHandler(http.MethodHead, health.LivePath, liveHandler)
|
||||||
|
|
||||||
|
// For everything else, serve maintenance template.
|
||||||
|
obj := map[string]string{"host": config.GetHost()}
|
||||||
|
r.AttachNoRouteHandler(func(c *gin.Context) {
|
||||||
|
retryAfter := time.Now().Add(120 * time.Second).UTC()
|
||||||
|
c.Writer.Header().Add("Retry-After", "120")
|
||||||
|
c.Writer.Header().Add("Retry-After", retryAfter.Format(http.TimeFormat))
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.HTML(http.StatusServiceUnavailable, "maintenance.tmpl", obj)
|
||||||
|
})
|
||||||
|
}
|
|
@ -21,14 +21,11 @@
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"codeberg.org/gruf/go-cache/v3"
|
"codeberg.org/gruf/go-cache/v3"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/middleware"
|
"github.com/superseriousbusiness/gotosocial/internal/middleware"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||||
"github.com/superseriousbusiness/gotosocial/internal/router"
|
"github.com/superseriousbusiness/gotosocial/internal/router"
|
||||||
|
@ -87,22 +84,22 @@ func New(db db.DB, processor *processing.Processor) *Module {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
|
// ETagCache implements withETagCache.
|
||||||
// Group all static files from assets dir at /assets,
|
func (m *Module) ETagCache() cache.Cache[string, eTagCacheEntry] {
|
||||||
// so that they can use the same cache control middleware.
|
return m.eTagCache
|
||||||
webAssetsAbsFilePath, err := filepath.Abs(config.GetWebAssetBaseDir())
|
}
|
||||||
if err != nil {
|
|
||||||
log.Panicf(nil, "error getting absolute path of assets dir: %s", err)
|
|
||||||
}
|
|
||||||
fs := fileSystem{http.Dir(webAssetsAbsFilePath)}
|
|
||||||
assetsGroup := r.AttachGroup(assetsPathPrefix)
|
|
||||||
assetsGroup.Use(m.assetsCacheControlMiddleware(fs))
|
|
||||||
assetsGroup.Use(mi...)
|
|
||||||
assetsGroup.StaticFS("/", fs)
|
|
||||||
|
|
||||||
// handlers that serve profiles and statuses should use the SignatureCheck
|
// Route attaches the assets filesystem and profile,
|
||||||
// middleware, so that requests with content-type application/activity+json
|
// status, and other web handlers to the router.
|
||||||
// can still be served
|
func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
|
||||||
|
// Route static assets.
|
||||||
|
routeAssets(m, r, mi...)
|
||||||
|
|
||||||
|
// Route all other endpoints + handlers.
|
||||||
|
//
|
||||||
|
// Handlers that serve profiles and statuses should use
|
||||||
|
// the SignatureCheck middleware, so that requests with
|
||||||
|
// content-type application/activity+json can be served
|
||||||
profileGroup := r.AttachGroup(profileGroupPath)
|
profileGroup := r.AttachGroup(profileGroupPath)
|
||||||
profileGroup.Use(mi...)
|
profileGroup.Use(mi...)
|
||||||
profileGroup.Use(middleware.SignatureCheck(m.isURIBlocked), middleware.CacheControl(middleware.CacheControlConfig{
|
profileGroup.Use(middleware.SignatureCheck(m.isURIBlocked), middleware.CacheControl(middleware.CacheControlConfig{
|
||||||
|
@ -111,7 +108,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
|
||||||
profileGroup.Handle(http.MethodGet, "", m.profileGETHandler) // use empty path here since it's the base of the group
|
profileGroup.Handle(http.MethodGet, "", m.profileGETHandler) // use empty path here since it's the base of the group
|
||||||
profileGroup.Handle(http.MethodGet, statusPath, m.threadGETHandler)
|
profileGroup.Handle(http.MethodGet, statusPath, m.threadGETHandler)
|
||||||
|
|
||||||
// Attach individual web handlers which require no specific middlewares
|
// Individual web handlers requiring no specific middlewares.
|
||||||
r.AttachHandler(http.MethodGet, "/", m.indexHandler) // front-page
|
r.AttachHandler(http.MethodGet, "/", m.indexHandler) // front-page
|
||||||
r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler)
|
r.AttachHandler(http.MethodGet, settingsPathPrefix, m.SettingsPanelHandler)
|
||||||
r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler)
|
r.AttachHandler(http.MethodGet, settingsPanelGlob, m.SettingsPanelHandler)
|
||||||
|
@ -128,7 +125,7 @@ func (m *Module) Route(r *router.Router, mi ...gin.HandlerFunc) {
|
||||||
r.AttachHandler(http.MethodGet, signupPath, m.signupGETHandler)
|
r.AttachHandler(http.MethodGet, signupPath, m.signupGETHandler)
|
||||||
r.AttachHandler(http.MethodPost, signupPath, m.signupPOSTHandler)
|
r.AttachHandler(http.MethodPost, signupPath, m.signupPOSTHandler)
|
||||||
|
|
||||||
// Attach redirects from old endpoints to current ones for backwards compatibility
|
// Redirects from old endpoints to for back compat.
|
||||||
r.AttachHandler(http.MethodGet, "/auth/edit", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) })
|
r.AttachHandler(http.MethodGet, "/auth/edit", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) })
|
||||||
r.AttachHandler(http.MethodGet, "/user", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) })
|
r.AttachHandler(http.MethodGet, "/user", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, userPanelPath) })
|
||||||
r.AttachHandler(http.MethodGet, "/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, adminPanelPath) })
|
r.AttachHandler(http.MethodGet, "/admin", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, adminPanelPath) })
|
||||||
|
|
|
@ -81,18 +81,6 @@ func(subscription *gtsmodel.WebPushSubscription) bool {
|
||||||
return gtserror.Newf("error getting VAPID key pair: %w", err)
|
return gtserror.Newf("error getting VAPID key pair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get contact email for this instance, if available.
|
|
||||||
domain := config.GetHost()
|
|
||||||
instance, err := r.state.DB.GetInstance(ctx, domain)
|
|
||||||
if err != nil {
|
|
||||||
return gtserror.Newf("error getting current instance: %w", err)
|
|
||||||
}
|
|
||||||
vapidSubjectEmail := instance.ContactEmail
|
|
||||||
if vapidSubjectEmail == "" {
|
|
||||||
// Instance contact email not configured. Use a dummy address.
|
|
||||||
vapidSubjectEmail = "admin@" + domain
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get target account settings.
|
// Get target account settings.
|
||||||
targetAccountSettings, err := r.state.DB.GetAccountSettings(ctx, notification.TargetAccountID)
|
targetAccountSettings, err := r.state.DB.GetAccountSettings(ctx, notification.TargetAccountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -111,7 +99,6 @@ func(subscription *gtsmodel.WebPushSubscription) bool {
|
||||||
if err := r.sendToSubscription(
|
if err := r.sendToSubscription(
|
||||||
ctx,
|
ctx,
|
||||||
vapidKeyPair,
|
vapidKeyPair,
|
||||||
vapidSubjectEmail,
|
|
||||||
targetAccountSettings,
|
targetAccountSettings,
|
||||||
subscription,
|
subscription,
|
||||||
notification,
|
notification,
|
||||||
|
@ -134,7 +121,6 @@ func(subscription *gtsmodel.WebPushSubscription) bool {
|
||||||
func (r *realSender) sendToSubscription(
|
func (r *realSender) sendToSubscription(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
vapidKeyPair *gtsmodel.VAPIDKeyPair,
|
vapidKeyPair *gtsmodel.VAPIDKeyPair,
|
||||||
vapidSubjectEmail string,
|
|
||||||
targetAccountSettings *gtsmodel.AccountSettings,
|
targetAccountSettings *gtsmodel.AccountSettings,
|
||||||
subscription *gtsmodel.WebPushSubscription,
|
subscription *gtsmodel.WebPushSubscription,
|
||||||
notification *gtsmodel.Notification,
|
notification *gtsmodel.Notification,
|
||||||
|
@ -185,7 +171,7 @@ func (r *realSender) sendToSubscription(
|
||||||
},
|
},
|
||||||
&webpushgo.Options{
|
&webpushgo.Options{
|
||||||
HTTPClient: r.httpClient,
|
HTTPClient: r.httpClient,
|
||||||
Subscriber: vapidSubjectEmail,
|
Subscriber: "https://" + config.GetHost(),
|
||||||
VAPIDPublicKey: vapidKeyPair.Public,
|
VAPIDPublicKey: vapidKeyPair.Public,
|
||||||
VAPIDPrivateKey: vapidKeyPair.Private,
|
VAPIDPrivateKey: vapidKeyPair.Private,
|
||||||
TTL: int(TTL.Seconds()),
|
TTL: int(TTL.Seconds()),
|
||||||
|
|
|
@ -118,6 +118,7 @@ EXPECT=$(cat << "EOF"
|
||||||
"nl",
|
"nl",
|
||||||
"en-GB"
|
"en-GB"
|
||||||
],
|
],
|
||||||
|
"instance-stats-randomize": true,
|
||||||
"instance-subscriptions-process-every": 86400000000000,
|
"instance-subscriptions-process-every": 86400000000000,
|
||||||
"instance-subscriptions-process-from": "23:00",
|
"instance-subscriptions-process-from": "23:00",
|
||||||
"landing-page-user": "admin",
|
"landing-page-user": "admin",
|
||||||
|
@ -248,6 +249,7 @@ GTS_INSTANCE_FEDERATION_SPAM_FILTER=true \
|
||||||
GTS_INSTANCE_DELIVER_TO_SHARED_INBOXES=false \
|
GTS_INSTANCE_DELIVER_TO_SHARED_INBOXES=false \
|
||||||
GTS_INSTANCE_INJECT_MASTODON_VERSION=true \
|
GTS_INSTANCE_INJECT_MASTODON_VERSION=true \
|
||||||
GTS_INSTANCE_LANGUAGES="nl,en-gb" \
|
GTS_INSTANCE_LANGUAGES="nl,en-gb" \
|
||||||
|
GTS_INSTANCE_STATS_RANDOMIZE=true \
|
||||||
GTS_ACCOUNTS_ALLOW_CUSTOM_CSS=true \
|
GTS_ACCOUNTS_ALLOW_CUSTOM_CSS=true \
|
||||||
GTS_ACCOUNTS_CUSTOM_CSS_LENGTH=5000 \
|
GTS_ACCOUNTS_CUSTOM_CSS_LENGTH=5000 \
|
||||||
GTS_ACCOUNTS_REGISTRATION_OPEN=true \
|
GTS_ACCOUNTS_REGISTRATION_OPEN=true \
|
||||||
|
|
76
web/template/maintenance.tmpl
Normal file
76
web/template/maintenance.tmpl
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
{{- /*
|
||||||
|
// GoToSocial
|
||||||
|
// Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<link rel="icon" href="/assets/logo.webp" type="image/webp">
|
||||||
|
<link rel="apple-touch-icon" href="/assets/logo.webp" type="image/webp">
|
||||||
|
<link rel="apple-touch-startup-image" href="/assets/logo.webp" type="image/webp">
|
||||||
|
<link rel="preload" href="/assets/dist/_colors.css" as="style">
|
||||||
|
<link rel="preload" href="/assets/dist/base.css" as="style">
|
||||||
|
<link rel="preload" href="/assets/dist/page.css" as="style">
|
||||||
|
<link rel="stylesheet" href="/assets/dist/_colors.css">
|
||||||
|
<link rel="stylesheet" href="/assets/dist/base.css">
|
||||||
|
<link rel="stylesheet" href="/assets/dist/page.css">
|
||||||
|
<title>{{- .host -}}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<header class="page-header">
|
||||||
|
<a aria-label="{{- .host -}}. Go to instance homepage" href="/" class="nounderline">
|
||||||
|
<picture>
|
||||||
|
<img
|
||||||
|
src="/assets/logo.webp"
|
||||||
|
alt="A cartoon sloth smiling happily."
|
||||||
|
title="A cartoon sloth smiling happily."
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
<h1>{{- .host -}}</h1>
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
<div class="page-content">
|
||||||
|
<p>This GoToSocial instance is currently down for maintenance, starting up, or running database migrations. Please wait.</p>
|
||||||
|
<p>If you are the admin of this instance, check your GoToSocial logs for more details, and make sure to <strong>not interrupt any running database migrations</strong>!</p>
|
||||||
|
</div>
|
||||||
|
<footer class="page-footer">
|
||||||
|
<nav>
|
||||||
|
<ul class="nodot">
|
||||||
|
<li id="version">
|
||||||
|
<a
|
||||||
|
href="https://github.com/superseriousbusiness/gotosocial"
|
||||||
|
class="nounderline"
|
||||||
|
rel="nofollow noreferrer noopener"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">🦥</span>
|
||||||
|
Source - GoToSocial
|
||||||
|
<span aria-hidden="true">🦥</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
Reference in a new issue