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
literal 0
HcmV?d00001
diff --git a/internal/media/util.go b/internal/media/util.go
index 9ffb79a46..64d1ee770 100644
--- a/internal/media/util.go
+++ b/internal/media/util.go
@@ -70,6 +70,36 @@ func supportedImageType(mimeType string) bool {
return false
}
+// supportedVideoType checks mime type of a video against a slice of accepted types,
+// and returns True if the mime type is accepted.
+func supportedVideoType(mimeType string) bool {
+ acceptedVideoTypes := []string{
+ "video/mp4",
+ "video/mpeg",
+ "video/webm",
+ }
+ for _, accepted := range acceptedVideoTypes {
+ if mimeType == accepted {
+ return true
+ }
+ }
+ return false
+}
+
+// supportedEmojiType checks that the content type is image/png -- the only type supported for emoji.
+func supportedEmojiType(mimeType string) bool {
+ acceptedEmojiTypes := []string{
+ "image/gif",
+ "image/png",
+ }
+ for _, accepted := range acceptedEmojiTypes {
+ if mimeType == accepted {
+ return true
+ }
+ }
+ return false
+}
+
// purgeExif is a little wrapper for the action of removing exif data from an image.
// Only pass pngs or jpegs to this function.
func purgeExif(b []byte) ([]byte, error) {
@@ -87,23 +117,12 @@ func purgeExif(b []byte) ([]byte, error) {
return clean, nil
}
-func deriveImage(b []byte, extension string) (*imageAndMeta, error) {
- var i image.Image
+func deriveGif(b []byte, extension string) (*imageAndMeta, error) {
+ var g *gif.GIF
var err error
-
switch extension {
- case "image/jpeg":
- i, err = jpeg.Decode(bytes.NewReader(b))
- if err != nil {
- return nil, err
- }
- case "image/png":
- i, err = png.Decode(bytes.NewReader(b))
- if err != nil {
- return nil, err
- }
case "image/gif":
- i, err = gif.Decode(bytes.NewReader(b))
+ g, err = gif.DecodeAll(bytes.NewReader(b))
if err != nil {
return nil, err
}
@@ -111,19 +130,22 @@ func deriveImage(b []byte, extension string) (*imageAndMeta, error) {
return nil, fmt.Errorf("extension %s not recognised", extension)
}
- width := i.Bounds().Size().X
- height := i.Bounds().Size().Y
+ // use the first frame to get the static characteristics
+ width := g.Config.Width
+ height := g.Config.Height
size := width * height
aspect := float64(width) / float64(height)
- bh, err := blurhash.Encode(4, 3, i)
- if err != nil {
- return nil, fmt.Errorf("error generating blurhash: %s", err)
+
+ bh, err := blurhash.Encode(4, 3, g.Image[0])
+ if err != nil || bh == "" {
+ return nil, err
}
out := &bytes.Buffer{}
- if err := jpeg.Encode(out, i, nil); err != nil {
+ if err := gif.EncodeAll(out, g); err != nil {
return nil, err
}
+
return &imageAndMeta{
image: out.Bytes(),
width: width,
@@ -134,16 +156,60 @@ func deriveImage(b []byte, extension string) (*imageAndMeta, error) {
}, nil
}
-// deriveThumbnailFromImage returns a byte slice and metadata for a 256-pixel-width thumbnail
-// of a given jpeg, png, or gif, or an error if something goes wrong.
-//
-// Note that the aspect ratio of the image will be retained,
-// so it will not necessarily be a square.
-func deriveThumbnail(b []byte, extension string) (*imageAndMeta, error) {
+func deriveImage(b []byte, contentType string) (*imageAndMeta, error) {
var i image.Image
var err error
- switch extension {
+ switch contentType {
+ case "image/jpeg":
+ i, err = jpeg.Decode(bytes.NewReader(b))
+ if err != nil {
+ return nil, err
+ }
+ case "image/png":
+ i, err = png.Decode(bytes.NewReader(b))
+ if err != nil {
+ return nil, err
+ }
+ default:
+ return nil, fmt.Errorf("content type %s not recognised", contentType)
+ }
+
+ width := i.Bounds().Size().X
+ height := i.Bounds().Size().Y
+ size := width * height
+ aspect := float64(width) / float64(height)
+
+ bh, err := blurhash.Encode(4, 3, i)
+ if err != nil {
+ return nil, err
+ }
+
+ out := &bytes.Buffer{}
+ if err := jpeg.Encode(out, i, nil); err != nil {
+ return nil, err
+ }
+
+ return &imageAndMeta{
+ image: out.Bytes(),
+ width: width,
+ height: height,
+ size: size,
+ aspect: aspect,
+ blurhash: bh,
+ }, nil
+}
+
+// deriveThumbnail returns a byte slice and metadata for a thumbnail of width x and height y,
+// of a given jpeg, png, or gif, or an error if something goes wrong.
+//
+// Note that the aspect ratio of the image will be retained,
+// so it will not necessarily be a square, even if x and y are set as the same value.
+func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMeta, error) {
+ var i image.Image
+ var err error
+
+ switch contentType {
case "image/jpeg":
i, err = jpeg.Decode(bytes.NewReader(b))
if err != nil {
@@ -160,10 +226,10 @@ func deriveThumbnail(b []byte, extension string) (*imageAndMeta, error) {
return nil, err
}
default:
- return nil, fmt.Errorf("extension %s not recognised", extension)
+ return nil, fmt.Errorf("content type %s not recognised", contentType)
}
- thumb := resize.Thumbnail(256, 256, i, resize.NearestNeighbor)
+ thumb := resize.Thumbnail(x, y, i, resize.NearestNeighbor)
width := thumb.Bounds().Size().X
height := thumb.Bounds().Size().Y
size := width * height
@@ -182,6 +248,35 @@ func deriveThumbnail(b []byte, extension string) (*imageAndMeta, error) {
}, nil
}
+// deriveStaticEmojji takes a given gif or png of an emoji, decodes it, and re-encodes it as a static png.
+func deriveStaticEmoji(b []byte, contentType string) (*imageAndMeta, error) {
+ var i image.Image
+ var err error
+
+ switch contentType {
+ case "image/png":
+ i, err = png.Decode(bytes.NewReader(b))
+ if err != nil {
+ return nil, err
+ }
+ case "image/gif":
+ i, err = gif.Decode(bytes.NewReader(b))
+ if err != nil {
+ return nil, err
+ }
+ default:
+ return nil, fmt.Errorf("content type %s not allowed for emoji", contentType)
+ }
+
+ out := &bytes.Buffer{}
+ if err := png.Encode(out, i); err != nil {
+ return nil, err
+ }
+ return &imageAndMeta{
+ image: out.Bytes(),
+ }, nil
+}
+
type imageAndMeta struct {
image []byte
width int
diff --git a/internal/media/util_test.go b/internal/media/util_test.go
index f24c1660f..be617a256 100644
--- a/internal/media/util_test.go
+++ b/internal/media/util_test.go
@@ -121,7 +121,7 @@ func (suite *MediaUtilTestSuite) TestDeriveThumbnailFromJPEG() {
assert.Nil(suite.T(), err)
// clean it up and validate the clean version
- imageAndMeta, err := deriveThumbnail(b, "image/jpeg")
+ imageAndMeta, err := deriveThumbnail(b, "image/jpeg", 256, 256)
assert.Nil(suite.T(), err)
assert.Equal(suite.T(), 256, imageAndMeta.width)
diff --git a/internal/oauth/server.go b/internal/oauth/server.go
index 8bac8fc2f..538288922 100644
--- a/internal/oauth/server.go
+++ b/internal/oauth/server.go
@@ -26,7 +26,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/db"
- "github.com/superseriousbusiness/gotosocial/internal/db/model"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
"github.com/superseriousbusiness/oauth2/v4"
"github.com/superseriousbusiness/oauth2/v4/errors"
"github.com/superseriousbusiness/oauth2/v4/manage"
@@ -34,6 +34,9 @@ import (
)
const (
+ // SessionAuthorizedToken is the key set in the gin context for the Token
+ // of a User who has successfully passed Bearer token authorization.
+ // The interface returned from grabbing this key should be parsed as oauth2.TokenInfo
SessionAuthorizedToken = "authorized_token"
// SessionAuthorizedUser is the key set in the gin context for the id of
// a User who has successfully passed Bearer token authorization.
@@ -65,9 +68,9 @@ type s struct {
type Authed struct {
Token oauth2.TokenInfo
- Application *model.Application
- User *model.User
- Account *model.Account
+ Application *gtsmodel.Application
+ User *gtsmodel.User
+ Account *gtsmodel.Account
}
// GetAuthed is a convenience function for returning an Authed struct from a gin context.
@@ -96,7 +99,7 @@ func GetAuthed(c *gin.Context) (*Authed, error) {
i, ok = ctx.Get(SessionAuthorizedApplication)
if ok {
- parsed, ok := i.(*model.Application)
+ parsed, ok := i.(*gtsmodel.Application)
if !ok {
return nil, errors.New("could not parse application from session context")
}
@@ -105,7 +108,7 @@ func GetAuthed(c *gin.Context) (*Authed, error) {
i, ok = ctx.Get(SessionAuthorizedUser)
if ok {
- parsed, ok := i.(*model.User)
+ parsed, ok := i.(*gtsmodel.User)
if !ok {
return nil, errors.New("could not parse user from session context")
}
@@ -114,7 +117,7 @@ func GetAuthed(c *gin.Context) (*Authed, error) {
i, ok = ctx.Get(SessionAuthorizedAccount)
if ok {
- parsed, ok := i.(*model.Account)
+ parsed, ok := i.(*gtsmodel.Account)
if !ok {
return nil, errors.New("could not parse account from session context")
}
diff --git a/internal/oauth/tokenstore.go b/internal/oauth/tokenstore.go
index c4c9ff1d5..14caa6581 100644
--- a/internal/oauth/tokenstore.go
+++ b/internal/oauth/tokenstore.go
@@ -98,7 +98,7 @@ func (pts *tokenStore) Create(ctx context.Context, info oauth2.TokenInfo) error
if !ok {
return errors.New("info param was not a models.Token")
}
- if err := pts.db.Put(oauthTokenToPGToken(t)); err != nil {
+ if err := pts.db.Put(OAuthTokenToPGToken(t)); err != nil {
return fmt.Errorf("error in tokenstore create: %s", err)
}
return nil
@@ -130,7 +130,7 @@ func (pts *tokenStore) GetByCode(ctx context.Context, code string) (oauth2.Token
if err := pts.db.GetWhere("code", code, pgt); err != nil {
return nil, err
}
- return pgTokenToOauthToken(pgt), nil
+ return PGTokenToOauthToken(pgt), nil
}
// GetByAccess selects a token from the DB based on the Access field
@@ -144,7 +144,7 @@ func (pts *tokenStore) GetByAccess(ctx context.Context, access string) (oauth2.T
if err := pts.db.GetWhere("access", access, pgt); err != nil {
return nil, err
}
- return pgTokenToOauthToken(pgt), nil
+ return PGTokenToOauthToken(pgt), nil
}
// GetByRefresh selects a token from the DB based on the Refresh field
@@ -158,7 +158,7 @@ func (pts *tokenStore) GetByRefresh(ctx context.Context, refresh string) (oauth2
if err := pts.db.GetWhere("refresh", refresh, pgt); err != nil {
return nil, err
}
- return pgTokenToOauthToken(pgt), nil
+ return PGTokenToOauthToken(pgt), nil
}
/*
@@ -194,8 +194,8 @@ type Token struct {
RefreshExpiresAt time.Time `pg:"type:timestamp"`
}
-// oauthTokenToPGToken is a lil util function that takes a gotosocial token and gives back a token for inserting into postgres
-func oauthTokenToPGToken(tkn *models.Token) *Token {
+// OAuthTokenToPGToken is a lil util function that takes a gotosocial token and gives back a token for inserting into postgres
+func OAuthTokenToPGToken(tkn *models.Token) *Token {
now := time.Now()
// For the following, we want to make sure we're not adding a time.Now() to an *empty* ExpiresIn, otherwise that's
@@ -236,8 +236,8 @@ func oauthTokenToPGToken(tkn *models.Token) *Token {
}
}
-// pgTokenToOauthToken is a lil util function that takes a postgres token and gives back a gotosocial token
-func pgTokenToOauthToken(pgt *Token) *models.Token {
+// PGTokenToOauthToken is a lil util function that takes a postgres token and gives back a gotosocial token
+func PGTokenToOauthToken(pgt *Token) *models.Token {
now := time.Now()
return &models.Token{
diff --git a/internal/router/router.go b/internal/router/router.go
index ce924b26d..7ab208ef6 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -83,7 +83,17 @@ func (r *router) AttachMiddleware(middleware gin.HandlerFunc) {
// New returns a new Router with the specified configuration, using the given logrus logger.
func New(config *config.Config, logger *logrus.Logger) (Router, error) {
- engine := gin.New()
+ lvl, err := logrus.ParseLevel(config.LogLevel)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't parse log level %s to set router level: %s", config.LogLevel, err)
+ }
+ switch lvl {
+ case logrus.TraceLevel, logrus.DebugLevel:
+ gin.SetMode(gin.DebugMode)
+ default:
+ gin.SetMode(gin.ReleaseMode)
+ }
+ engine := gin.Default()
// create a new session store middleware
store, err := sessionStore()
diff --git a/internal/storage/inmem.go b/internal/storage/inmem.go
index 25432fbaa..2d88189db 100644
--- a/internal/storage/inmem.go
+++ b/internal/storage/inmem.go
@@ -7,25 +7,49 @@ import (
"github.com/superseriousbusiness/gotosocial/internal/config"
)
+// NewInMem returns an in-memory implementation of the Storage interface.
+// This is good for testing and whatnot but ***SHOULD ABSOLUTELY NOT EVER
+// BE USED IN A PRODUCTION SETTING***, because A) everything will be wiped out
+// if you restart the server and B) if you store lots of images your RAM use
+// will absolutely go through the roof.
func NewInMem(c *config.Config, log *logrus.Logger) (Storage, error) {
return &inMemStorage{
stored: make(map[string][]byte),
+ log: log,
}, nil
}
type inMemStorage struct {
stored map[string][]byte
+ log *logrus.Logger
}
func (s *inMemStorage) StoreFileAt(path string, data []byte) error {
+ l := s.log.WithField("func", "StoreFileAt")
+ l.Debugf("storing at path %s", path)
s.stored[path] = data
return nil
}
func (s *inMemStorage) RetrieveFileFrom(path string) ([]byte, error) {
+ l := s.log.WithField("func", "RetrieveFileFrom")
+ l.Debugf("retrieving from path %s", path)
d, ok := s.stored[path]
if !ok {
return nil, fmt.Errorf("no data found at path %s", path)
}
return d, nil
}
+
+func (s *inMemStorage) ListKeys() ([]string, error) {
+ keys := []string{}
+ for k := range s.stored {
+ keys = append(keys, k)
+ }
+ return keys, nil
+}
+
+func (s *inMemStorage) RemoveFileAt(path string) error {
+ delete(s.stored, path)
+ return nil
+}
diff --git a/internal/storage/local.go b/internal/storage/local.go
index 29461d5d4..3b64524f6 100644
--- a/internal/storage/local.go
+++ b/internal/storage/local.go
@@ -1,21 +1,70 @@
package storage
import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/config"
)
+// NewLocal returns an implementation of the Storage interface that uses
+// the local filesystem for storing and retrieving files, attachments, etc.
func NewLocal(c *config.Config, log *logrus.Logger) (Storage, error) {
- return &localStorage{}, nil
+ return &localStorage{
+ config: c,
+ log: log,
+ }, nil
}
type localStorage struct {
+ config *config.Config
+ log *logrus.Logger
}
func (s *localStorage) StoreFileAt(path string, data []byte) error {
+ l := s.log.WithField("func", "StoreFileAt")
+ l.Debugf("storing at path %s", path)
+ components := strings.Split(path, "/")
+ dir := strings.Join(components[0:len(components)-1], "/")
+ if err := os.MkdirAll(dir, 0777); err != nil {
+ return fmt.Errorf("error writing file at %s: %s", path, err)
+ }
+ if err := os.WriteFile(path, data, 0777); err != nil {
+ return fmt.Errorf("error writing file at %s: %s", path, err)
+ }
return nil
}
func (s *localStorage) RetrieveFileFrom(path string) ([]byte, error) {
- return nil, nil
+ l := s.log.WithField("func", "RetrieveFileFrom")
+ l.Debugf("retrieving from path %s", path)
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("error reading file at %s: %s", path, err)
+ }
+ return b, nil
+}
+
+func (s *localStorage) ListKeys() ([]string, error) {
+ keys := []string{}
+ err := filepath.Walk(s.config.StorageConfig.BasePath, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if !info.IsDir() {
+ keys = append(keys, path)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ return keys, nil
+}
+
+func (s *localStorage) RemoveFileAt(path string) error {
+ return os.Remove(path)
}
diff --git a/internal/storage/mock_Storage.go b/internal/storage/mock_Storage.go
index 865d52205..2444f030a 100644
--- a/internal/storage/mock_Storage.go
+++ b/internal/storage/mock_Storage.go
@@ -9,6 +9,43 @@ type MockStorage struct {
mock.Mock
}
+// ListKeys provides a mock function with given fields:
+func (_m *MockStorage) ListKeys() ([]string, error) {
+ ret := _m.Called()
+
+ var r0 []string
+ if rf, ok := ret.Get(0).(func() []string); ok {
+ r0 = rf()
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).([]string)
+ }
+ }
+
+ var r1 error
+ if rf, ok := ret.Get(1).(func() error); ok {
+ r1 = rf()
+ } else {
+ r1 = ret.Error(1)
+ }
+
+ return r0, r1
+}
+
+// RemoveFileAt provides a mock function with given fields: path
+func (_m *MockStorage) RemoveFileAt(path string) error {
+ ret := _m.Called(path)
+
+ var r0 error
+ if rf, ok := ret.Get(0).(func(string) error); ok {
+ r0 = rf(path)
+ } else {
+ r0 = ret.Error(0)
+ }
+
+ return r0
+}
+
// RetrieveFileFrom provides a mock function with given fields: path
func (_m *MockStorage) RetrieveFileFrom(path string) ([]byte, error) {
ret := _m.Called(path)
diff --git a/internal/storage/storage.go b/internal/storage/storage.go
index fa884ed07..409c90b37 100644
--- a/internal/storage/storage.go
+++ b/internal/storage/storage.go
@@ -16,9 +16,15 @@
along with this program. If not, see .
*/
+// Package storage contains an interface and implementations for storing and retrieving files and attachments.
package storage
+// Storage is an interface for storing and retrieving blobs
+// such as images, videos, and any other attachments/documents
+// that shouldn't be stored in a database.
type Storage interface {
StoreFileAt(path string, data []byte) error
RetrieveFileFrom(path string) ([]byte, error)
+ ListKeys() ([]string, error)
+ RemoveFileAt(path string) error
}
diff --git a/internal/util/parse.go b/internal/util/parse.go
index 375ab97f2..f0bcff5dc 100644
--- a/internal/util/parse.go
+++ b/internal/util/parse.go
@@ -1,32 +1,96 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
package util
-import "fmt"
+import (
+ "fmt"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ mastotypes "github.com/superseriousbusiness/gotosocial/internal/mastotypes/mastomodel"
+)
+
+// URIs contains a bunch of URIs and URLs for a user, host, account, etc.
type URIs struct {
- HostURL string
- UserURL string
+ HostURL string
+ UserURL string
+ StatusesURL string
+
UserURI string
- InboxURL string
- OutboxURL string
- FollowersURL string
- CollectionURL string
+ StatusesURI string
+ InboxURI string
+ OutboxURI string
+ FollowersURI string
+ CollectionURI string
}
+// GenerateURIs throws together a bunch of URIs for the given username, with the given protocol and host.
func GenerateURIs(username string, protocol string, host string) *URIs {
hostURL := fmt.Sprintf("%s://%s", protocol, host)
userURL := fmt.Sprintf("%s/@%s", hostURL, username)
+ statusesURL := fmt.Sprintf("%s/statuses", userURL)
+
userURI := fmt.Sprintf("%s/users/%s", hostURL, username)
- inboxURL := fmt.Sprintf("%s/inbox", userURI)
- outboxURL := fmt.Sprintf("%s/outbox", userURI)
- followersURL := fmt.Sprintf("%s/followers", userURI)
- collectionURL := fmt.Sprintf("%s/collections/featured", userURI)
+ statusesURI := fmt.Sprintf("%s/statuses", userURI)
+ inboxURI := fmt.Sprintf("%s/inbox", userURI)
+ outboxURI := fmt.Sprintf("%s/outbox", userURI)
+ followersURI := fmt.Sprintf("%s/followers", userURI)
+ collectionURI := fmt.Sprintf("%s/collections/featured", userURI)
return &URIs{
- HostURL: hostURL,
- UserURL: userURL,
+ HostURL: hostURL,
+ UserURL: userURL,
+ StatusesURL: statusesURL,
+
UserURI: userURI,
- InboxURL: inboxURL,
- OutboxURL: outboxURL,
- FollowersURL: followersURL,
- CollectionURL: collectionURL,
+ StatusesURI: statusesURI,
+ InboxURI: inboxURI,
+ OutboxURI: outboxURI,
+ FollowersURI: followersURI,
+ CollectionURI: collectionURI,
}
}
+
+// ParseGTSVisFromMastoVis converts a mastodon visibility into its gts equivalent.
+func ParseGTSVisFromMastoVis(m mastotypes.Visibility) gtsmodel.Visibility {
+ switch m {
+ case mastotypes.VisibilityPublic:
+ return gtsmodel.VisibilityPublic
+ case mastotypes.VisibilityUnlisted:
+ return gtsmodel.VisibilityUnlocked
+ case mastotypes.VisibilityPrivate:
+ return gtsmodel.VisibilityFollowersOnly
+ case mastotypes.VisibilityDirect:
+ return gtsmodel.VisibilityDirect
+ }
+ return ""
+}
+
+// ParseMastoVisFromGTSVis converts a gts visibility into its mastodon equivalent
+func ParseMastoVisFromGTSVis(m gtsmodel.Visibility) mastotypes.Visibility {
+ switch m {
+ case gtsmodel.VisibilityPublic:
+ return mastotypes.VisibilityPublic
+ case gtsmodel.VisibilityUnlocked:
+ return mastotypes.VisibilityUnlisted
+ case gtsmodel.VisibilityFollowersOnly, gtsmodel.VisibilityMutualsOnly:
+ return mastotypes.VisibilityPrivate
+ case gtsmodel.VisibilityDirect:
+ return mastotypes.VisibilityDirect
+ }
+ return ""
+}
diff --git a/internal/util/regexes.go b/internal/util/regexes.go
new file mode 100644
index 000000000..60b397d86
--- /dev/null
+++ b/internal/util/regexes.go
@@ -0,0 +1,36 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package util
+
+import "regexp"
+
+var (
+ // mention regex can be played around with here: https://regex101.com/r/qwM9D3/1
+ mentionRegexString = `(?: |^|\W)(@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9_\-\.]+)?)(?: |\n)`
+ mentionRegex = regexp.MustCompile(mentionRegexString)
+ // hashtag regex can be played with here: https://regex101.com/r/Vhy8pg/1
+ hashtagRegexString = `(?: |^|\W)?#([a-zA-Z0-9]{1,30})(?:\b|\r)`
+ hashtagRegex = regexp.MustCompile(hashtagRegexString)
+ // emoji regex can be played with here: https://regex101.com/r/478XGM/1
+ emojiRegexString = `(?: |^|\W)?:([a-zA-Z0-9_]{2,30}):(?:\b|\r)?`
+ emojiRegex = regexp.MustCompile(emojiRegexString)
+ // emoji shortcode regex can be played with here: https://regex101.com/r/zMDRaG/1
+ emojiShortcodeString = `^[a-z0-9_]{2,30}$`
+ emojiShortcodeRegex = regexp.MustCompile(emojiShortcodeString)
+)
diff --git a/internal/util/status.go b/internal/util/status.go
new file mode 100644
index 000000000..e4b3ec6a5
--- /dev/null
+++ b/internal/util/status.go
@@ -0,0 +1,96 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package util
+
+import (
+ "strings"
+)
+
+// DeriveMentions takes a plaintext (ie., not html-formatted) status,
+// and applies a regex to it to return a deduplicated list of accounts
+// mentioned in that status.
+//
+// It will look for fully-qualified account names in the form "@user@example.org".
+// or the form "@username" for local users.
+// The case of the returned mentions will be lowered, for consistency.
+func DeriveMentions(status string) []string {
+ mentionedAccounts := []string{}
+ for _, m := range mentionRegex.FindAllStringSubmatch(status, -1) {
+ mentionedAccounts = append(mentionedAccounts, m[1])
+ }
+ return Lower(Unique(mentionedAccounts))
+}
+
+// DeriveHashtags takes a plaintext (ie., not html-formatted) status,
+// and applies a regex to it to return a deduplicated list of hashtags
+// used in that status, without the leading #. The case of the returned
+// tags will be lowered, for consistency.
+func DeriveHashtags(status string) []string {
+ tags := []string{}
+ for _, m := range hashtagRegex.FindAllStringSubmatch(status, -1) {
+ tags = append(tags, m[1])
+ }
+ return Lower(Unique(tags))
+}
+
+// DeriveEmojis takes a plaintext (ie., not html-formatted) status,
+// and applies a regex to it to return a deduplicated list of emojis
+// used in that status, without the surround ::. The case of the returned
+// emojis will be lowered, for consistency.
+func DeriveEmojis(status string) []string {
+ emojis := []string{}
+ for _, m := range emojiRegex.FindAllStringSubmatch(status, -1) {
+ emojis = append(emojis, m[1])
+ }
+ return Lower(Unique(emojis))
+}
+
+// Unique returns a deduplicated version of a given string slice.
+func Unique(s []string) []string {
+ keys := make(map[string]bool)
+ list := []string{}
+ for _, entry := range s {
+ if _, value := keys[entry]; !value {
+ keys[entry] = true
+ list = append(list, entry)
+ }
+ }
+ return list
+}
+
+// Lower lowercases all strings in a given string slice
+func Lower(s []string) []string {
+ new := []string{}
+ for _, i := range s {
+ new = append(new, strings.ToLower(i))
+ }
+ return new
+}
+
+// HTMLFormat takes a plaintext formatted status string, and converts it into
+// a nice HTML-formatted string.
+//
+// This includes:
+// - Replacing line-breaks with
+// - Replacing URLs with hrefs.
+// - Replacing mentions with links to that account's URL as stored in the database.
+func HTMLFormat(status string) string {
+ // TODO: write proper HTML formatting logic for a status
+ return status
+}
diff --git a/internal/util/status_test.go b/internal/util/status_test.go
new file mode 100644
index 000000000..72bd3e885
--- /dev/null
+++ b/internal/util/status_test.go
@@ -0,0 +1,105 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package util
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/suite"
+)
+
+type StatusTestSuite struct {
+ suite.Suite
+}
+
+func (suite *StatusTestSuite) TestDeriveMentionsOK() {
+ statusText := `@dumpsterqueer@example.org testing testing
+
+ is this thing on?
+
+ @someone_else@testing.best-horse.com can you confirm? @hello@test.lgbt
+
+ @thisisalocaluser ! @NORWILL@THIS.one!!
+
+ here is a duplicate mention: @hello@test.lgbt
+ `
+
+ menchies := DeriveMentions(statusText)
+ assert.Len(suite.T(), menchies, 4)
+ assert.Equal(suite.T(), "@dumpsterqueer@example.org", menchies[0])
+ assert.Equal(suite.T(), "@someone_else@testing.best-horse.com", menchies[1])
+ assert.Equal(suite.T(), "@hello@test.lgbt", menchies[2])
+ assert.Equal(suite.T(), "@thisisalocaluser", menchies[3])
+}
+
+func (suite *StatusTestSuite) TestDeriveMentionsEmpty() {
+ statusText := ``
+ menchies := DeriveMentions(statusText)
+ assert.Len(suite.T(), menchies, 0)
+}
+
+func (suite *StatusTestSuite) TestDeriveHashtagsOK() {
+ statusText := `#testing123 #also testing
+
+# testing this one shouldn't work
+
+ #thisshouldwork
+
+#ThisShouldAlsoWork #not_this_though
+
+#111111 thisalsoshouldn'twork#### ##`
+
+ tags := DeriveHashtags(statusText)
+ assert.Len(suite.T(), tags, 5)
+ assert.Equal(suite.T(), "testing123", tags[0])
+ assert.Equal(suite.T(), "also", tags[1])
+ assert.Equal(suite.T(), "thisshouldwork", tags[2])
+ assert.Equal(suite.T(), "thisshouldalsowork", tags[3])
+ assert.Equal(suite.T(), "111111", tags[4])
+}
+
+func (suite *StatusTestSuite) TestDeriveEmojiOK() {
+ statusText := `:test: :another:
+
+Here's some normal text with an :emoji: at the end
+
+:spaces shouldnt work:
+
+:emoji1::emoji2:
+
+:anotheremoji:emoji2:
+:anotheremoji::anotheremoji::anotheremoji::anotheremoji:
+:underscores_ok_too:
+`
+
+ tags := DeriveEmojis(statusText)
+ assert.Len(suite.T(), tags, 7)
+ assert.Equal(suite.T(), "test", tags[0])
+ assert.Equal(suite.T(), "another", tags[1])
+ assert.Equal(suite.T(), "emoji", tags[2])
+ assert.Equal(suite.T(), "emoji1", tags[3])
+ assert.Equal(suite.T(), "emoji2", tags[4])
+ assert.Equal(suite.T(), "anotheremoji", tags[5])
+ assert.Equal(suite.T(), "underscores_ok_too", tags[6])
+}
+
+func TestStatusTestSuite(t *testing.T) {
+ suite.Run(t, new(StatusTestSuite))
+}
diff --git a/internal/util/validation.go b/internal/util/validation.go
index 88a56875c..8102bc35d 100644
--- a/internal/util/validation.go
+++ b/internal/util/validation.go
@@ -142,3 +142,13 @@ func ValidatePrivacy(privacy string) error {
// TODO: add some validation logic here -- length, characters, etc
return nil
}
+
+// ValidateEmojiShortcode just runs the given shortcode through the regular expression
+// for emoji shortcodes, to figure out whether it's a valid shortcode, ie., 2-30 characters,
+// lowercase a-z, numbers, and underscores.
+func ValidateEmojiShortcode(shortcode string) error {
+ if !emojiShortcodeRegex.MatchString(shortcode) {
+ return fmt.Errorf("shortcode %s did not pass validation, must be between 2 and 30 characters, lowercase letters, numbers, and underscores only", shortcode)
+ }
+ return nil
+}
diff --git a/scripts/auth_flow.sh b/scripts/auth_flow.sh
index 8bba39532..5552349a5 100755
--- a/scripts/auth_flow.sh
+++ b/scripts/auth_flow.sh
@@ -5,10 +5,9 @@ set -eux
SERVER_URL="http://localhost:8080"
REDIRECT_URI="${SERVER_URL}"
CLIENT_NAME="Test Application Name"
-
REGISTRATION_REASON="Testing whether or not this dang diggity thing works!"
-REGISTRATION_EMAIL="test@example.org"
-REGISTRATION_USERNAME="test_user"
+REGISTRATION_USERNAME="${1}"
+REGISTRATION_EMAIL="${2}"
REGISTRATION_PASSWORD="very safe password 123"
REGISTRATION_AGREEMENT="true"
REGISTRATION_LOCALE="en"
diff --git a/testrig/actions.go b/testrig/actions.go
new file mode 100644
index 000000000..1caa18581
--- /dev/null
+++ b/testrig/actions.go
@@ -0,0 +1,125 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package testrig
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/action"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/account"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/admin"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/app"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/auth"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/fileserver"
+ mediaModule "github.com/superseriousbusiness/gotosocial/internal/apimodule/media"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/security"
+ "github.com/superseriousbusiness/gotosocial/internal/apimodule/status"
+ "github.com/superseriousbusiness/gotosocial/internal/cache"
+ "github.com/superseriousbusiness/gotosocial/internal/config"
+ "github.com/superseriousbusiness/gotosocial/internal/federation"
+ "github.com/superseriousbusiness/gotosocial/internal/gotosocial"
+)
+
+// Run creates and starts a gotosocial testrig server
+var Run action.GTSAction = func(ctx context.Context, _ *config.Config, log *logrus.Logger) error {
+ dbService := NewTestDB()
+ router := NewTestRouter()
+ storageBackend := NewTestStorage()
+ mediaHandler := NewTestMediaHandler(dbService, storageBackend)
+ oauthServer := NewTestOauthServer(dbService)
+ distributor := NewTestDistributor()
+ if err := distributor.Start(); err != nil {
+ return fmt.Errorf("error starting distributor: %s", err)
+ }
+ mastoConverter := NewTestMastoConverter(dbService)
+
+ c := NewTestConfig()
+
+ StandardDBSetup(dbService)
+ StandardStorageSetup(storageBackend, "./testrig/media")
+
+ // build client api modules
+ authModule := auth.New(oauthServer, dbService, log)
+ accountModule := account.New(c, dbService, oauthServer, mediaHandler, mastoConverter, log)
+ appsModule := app.New(oauthServer, dbService, mastoConverter, log)
+ mm := mediaModule.New(dbService, mediaHandler, mastoConverter, c, log)
+ fileServerModule := fileserver.New(c, dbService, storageBackend, log)
+ adminModule := admin.New(c, dbService, mediaHandler, mastoConverter, log)
+ statusModule := status.New(c, dbService, mediaHandler, mastoConverter, distributor, log)
+ securityModule := security.New(c, log)
+
+ apiModules := []apimodule.ClientAPIModule{
+ // modules with middleware go first
+ securityModule,
+ authModule,
+
+ // now everything else
+ accountModule,
+ appsModule,
+ mm,
+ fileServerModule,
+ adminModule,
+ statusModule,
+ }
+
+ for _, m := range apiModules {
+ if err := m.Route(router); err != nil {
+ return fmt.Errorf("routing error: %s", err)
+ }
+ if err := m.CreateTables(dbService); err != nil {
+ return fmt.Errorf("table creation error: %s", err)
+ }
+ }
+
+ // if err := dbService.CreateInstanceAccount(); err != nil {
+ // return fmt.Errorf("error creating instance account: %s", err)
+ // }
+
+ gts, err := gotosocial.New(dbService, &cache.MockCache{}, router, federation.New(dbService, log), c)
+ if err != nil {
+ return fmt.Errorf("error creating gotosocial service: %s", err)
+ }
+
+ if err := gts.Start(ctx); err != nil {
+ return fmt.Errorf("error starting gotosocial service: %s", err)
+ }
+
+ // catch shutdown signals from the operating system
+ sigs := make(chan os.Signal, 1)
+ signal.Notify(sigs, os.Interrupt, syscall.SIGTERM)
+ sig := <-sigs
+ log.Infof("received signal %s, shutting down", sig)
+
+ StandardDBTeardown(dbService)
+ StandardStorageTeardown(storageBackend)
+
+ // close down all running services in order
+ if err := gts.Stop(ctx); err != nil {
+ return fmt.Errorf("error closing gotosocial service: %s", err)
+ }
+
+ log.Info("done! exiting...")
+ return nil
+}
diff --git a/testrig/config.go b/testrig/config.go
new file mode 100644
index 000000000..f7028b1b5
--- /dev/null
+++ b/testrig/config.go
@@ -0,0 +1,26 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package testrig
+
+import "github.com/superseriousbusiness/gotosocial/internal/config"
+
+// NewTestConfig returns a config initialized with test defaults
+func NewTestConfig() *config.Config {
+ return config.TestDefault()
+}
diff --git a/testrig/db.go b/testrig/db.go
new file mode 100644
index 000000000..5974eae69
--- /dev/null
+++ b/testrig/db.go
@@ -0,0 +1,144 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package testrig
+
+import (
+ "context"
+
+ "github.com/sirupsen/logrus"
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/db/gtsmodel"
+ "github.com/superseriousbusiness/gotosocial/internal/oauth"
+)
+
+var testModels []interface{} = []interface{}{
+ >smodel.Account{},
+ >smodel.Application{},
+ >smodel.Block{},
+ >smodel.DomainBlock{},
+ >smodel.EmailDomainBlock{},
+ >smodel.Follow{},
+ >smodel.FollowRequest{},
+ >smodel.MediaAttachment{},
+ >smodel.Mention{},
+ >smodel.Status{},
+ >smodel.StatusFave{},
+ >smodel.StatusBookmark{},
+ >smodel.StatusMute{},
+ >smodel.StatusPin{},
+ >smodel.Tag{},
+ >smodel.User{},
+ >smodel.Emoji{},
+ &oauth.Token{},
+ &oauth.Client{},
+}
+
+// NewTestDB returns a new initialized, empty database for testing
+func NewTestDB() db.DB {
+ config := NewTestConfig()
+ l := logrus.New()
+ l.SetLevel(logrus.TraceLevel)
+ testDB, err := db.New(context.Background(), config, l)
+ if err != nil {
+ panic(err)
+ }
+ return testDB
+}
+
+// StandardDBSetup populates a given db with all the necessary tables/models for perfoming tests.
+func StandardDBSetup(db db.DB) {
+ for _, m := range testModels {
+ if err := db.CreateTable(m); err != nil {
+ panic(err)
+ }
+ }
+
+ for _, v := range NewTestTokens() {
+ if err := db.Put(v); err != nil {
+ panic(err)
+ }
+ }
+
+ for _, v := range NewTestClients() {
+ if err := db.Put(v); err != nil {
+ panic(err)
+ }
+ }
+
+ for _, v := range NewTestApplications() {
+ if err := db.Put(v); err != nil {
+ panic(err)
+ }
+ }
+
+ for _, v := range NewTestUsers() {
+ if err := db.Put(v); err != nil {
+ panic(err)
+ }
+ }
+
+ for _, v := range NewTestAccounts() {
+ if err := db.Put(v); err != nil {
+ panic(err)
+ }
+ }
+
+ for _, v := range NewTestAttachments() {
+ if err := db.Put(v); err != nil {
+ panic(err)
+ }
+ }
+
+ for _, v := range NewTestStatuses() {
+ if err := db.Put(v); err != nil {
+ panic(err)
+ }
+ }
+
+ for _, v := range NewTestEmojis() {
+ if err := db.Put(v); err != nil {
+ panic(err)
+ }
+ }
+
+ for _, v := range NewTestTags() {
+ if err := db.Put(v); err != nil {
+ panic(err)
+ }
+ }
+
+ for _, v := range NewTestFaves() {
+ if err := db.Put(v); err != nil {
+ panic(err)
+ }
+ }
+
+ if err := db.CreateInstanceAccount(); err != nil {
+ panic(err)
+ }
+}
+
+// StandardDBTeardown drops all the standard testing tables/models from the database to ensure it's clean for the next test.
+func StandardDBTeardown(db db.DB) {
+ for _, m := range testModels {
+ if err := db.DropTable(m); err != nil {
+ panic(err)
+ }
+ }
+}
diff --git a/testrig/distributor.go b/testrig/distributor.go
new file mode 100644
index 000000000..e21321d53
--- /dev/null
+++ b/testrig/distributor.go
@@ -0,0 +1,25 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package testrig
+
+import "github.com/superseriousbusiness/gotosocial/internal/distributor"
+
+func NewTestDistributor() distributor.Distributor {
+ return distributor.New(NewTestLog())
+}
diff --git a/testrig/log.go b/testrig/log.go
new file mode 100644
index 000000000..0bafc96f7
--- /dev/null
+++ b/testrig/log.go
@@ -0,0 +1,28 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package testrig
+
+import "github.com/sirupsen/logrus"
+
+// NewTestLog returns a trace level logger for testing
+func NewTestLog() *logrus.Logger {
+ log := logrus.New()
+ log.SetLevel(logrus.TraceLevel)
+ return log
+}
diff --git a/testrig/mastoconverter.go b/testrig/mastoconverter.go
new file mode 100644
index 000000000..10bdbdc95
--- /dev/null
+++ b/testrig/mastoconverter.go
@@ -0,0 +1,29 @@
+/*
+ GoToSocial
+ Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+package testrig
+
+import (
+ "github.com/superseriousbusiness/gotosocial/internal/db"
+ "github.com/superseriousbusiness/gotosocial/internal/mastotypes"
+)
+
+// NewTestMastoConverter returned a mastotypes converter with the given db and the default test config
+func NewTestMastoConverter(db db.DB) mastotypes.Converter {
+ return mastotypes.New(NewTestConfig(), db)
+}
diff --git a/testrig/media/ohyou-original.jpeg b/testrig/media/ohyou-original.jpeg
new file mode 100755
index 0000000000000000000000000000000000000000..3499651607f3359b20366c1af75a786bfa30cfe1
GIT binary patch
literal 27759
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