diff --git a/internal/api/s2s/user/user.go b/internal/api/s2s/user/user.go index 74e3b555e..693fac7c3 100644 --- a/internal/api/s2s/user/user.go +++ b/internal/api/s2s/user/user.go @@ -34,10 +34,10 @@ const ( UsernameKey = "username" // UsersBasePath is the base path for serving information about Users eg https://example.org/users UsersBasePath = "/" + util.UsersPath - // UsersBasePathWithID is just the users base path with the Username key in it. + // UsersBasePathWithUsername is just the users base path with the Username key in it. // Use this anywhere you need to know the username of the user being queried. // Eg https://example.org/users/:username - UsersBasePathWithID = UsersBasePath + "/:" + UsernameKey + UsersBasePathWithUsername = UsersBasePath + "/:" + UsernameKey ) // ActivityPubAcceptHeaders represents the Accept headers mentioned here: @@ -65,6 +65,6 @@ func New(config *config.Config, processor message.Processor, log *logrus.Logger) // Route satisfies the RESTAPIModule interface func (m *Module) Route(s router.Router) error { - s.AttachHandler(http.MethodGet, UsersBasePathWithID, m.UsersGETHandler) + s.AttachHandler(http.MethodGet, UsersBasePathWithUsername, m.UsersGETHandler) return nil } diff --git a/internal/api/s2s/user/user_test.go b/internal/api/s2s/user/user_test.go new file mode 100644 index 000000000..d3dae1920 --- /dev/null +++ b/internal/api/s2s/user/user_test.go @@ -0,0 +1,39 @@ +package user_test + +import ( + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" + "github.com/superseriousbusiness/gotosocial/internal/federation" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/message" + "github.com/superseriousbusiness/gotosocial/internal/oauth" + "github.com/superseriousbusiness/gotosocial/internal/storage" + "github.com/superseriousbusiness/gotosocial/internal/typeutils" +) + +type UserStandardTestSuite struct { + // standard suite interfaces + suite.Suite + config *config.Config + db db.DB + log *logrus.Logger + tc typeutils.TypeConverter + federator federation.Federator + processor message.Processor + storage storage.Storage + + // standard suite models + testTokens map[string]*oauth.Token + testClients map[string]*oauth.Client + testApplications map[string]*gtsmodel.Application + testUsers map[string]*gtsmodel.User + testAccounts map[string]*gtsmodel.Account + testAttachments map[string]*gtsmodel.MediaAttachment + testStatuses map[string]*gtsmodel.Status + + // module being tested + userModule *user.Module +} diff --git a/internal/api/s2s/user/userget_test.go b/internal/api/s2s/user/userget_test.go new file mode 100644 index 000000000..0859582f4 --- /dev/null +++ b/internal/api/s2s/user/userget_test.go @@ -0,0 +1,155 @@ +package user_test + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/go-fed/activity/streams" + "github.com/go-fed/activity/streams/vocab" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/api/s2s/user" + "github.com/superseriousbusiness/gotosocial/testrig" +) + +type UserGetTestSuite struct { + UserStandardTestSuite +} + +func (suite *UserGetTestSuite) SetupSuite() { + suite.testTokens = testrig.NewTestTokens() + suite.testClients = testrig.NewTestClients() + suite.testApplications = testrig.NewTestApplications() + suite.testUsers = testrig.NewTestUsers() + suite.testAccounts = testrig.NewTestAccounts() + suite.testAttachments = testrig.NewTestAttachments() + suite.testStatuses = testrig.NewTestStatuses() +} + +func (suite *UserGetTestSuite) SetupTest() { + suite.config = testrig.NewTestConfig() + suite.db = testrig.NewTestDB() + suite.tc = testrig.NewTestTypeConverter(suite.db) + suite.storage = testrig.NewTestStorage() + suite.log = testrig.NewTestLog() + suite.federator = testrig.NewTestFederator(suite.db, testrig.NewTestTransportController(testrig.NewMockHTTPClient(nil))) + suite.processor = testrig.NewTestProcessor(suite.db, suite.storage, suite.federator) + suite.userModule = user.New(suite.config, suite.processor, suite.log).(*user.Module) + testrig.StandardDBSetup(suite.db) + testrig.StandardStorageSetup(suite.storage, "../../../../testrig/media") +} + +func (suite *UserGetTestSuite) TearDownTest() { + testrig.StandardDBTeardown(suite.db) + testrig.StandardStorageTeardown(suite.storage) +} + +func (suite *UserGetTestSuite) TestGetUser() { + // the dereference we're gonna use + signedRequest := testrig.NewTestDereferenceRequests(suite.testAccounts)["foss_satan_dereference_zork"] + + requestingAccount := suite.testAccounts["remote_account_1"] + targetAccount := suite.testAccounts["local_account_1"] + + encodedPublicKey, err := x509.MarshalPKIXPublicKey(requestingAccount.PublicKey) + assert.NoError(suite.T(), err) + publicKeyBytes := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: encodedPublicKey, + }) + publicKeyString := strings.ReplaceAll(string(publicKeyBytes), "\n", "\\n") + + // for this test we need the client to return the public key of the requester on the 'remote' instance + responseBodyString := fmt.Sprintf(` + { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1" + ], + + "id": "%s", + "type": "Person", + "preferredUsername": "%s", + "inbox": "%s", + + "publicKey": { + "id": "%s", + "owner": "%s", + "publicKeyPem": "%s" + } + }`, requestingAccount.URI, requestingAccount.Username, requestingAccount.InboxURI, requestingAccount.PublicKeyURI, requestingAccount.URI, publicKeyString) + + // create a transport controller whose client will just return the response body string we specified above + tc := testrig.NewTestTransportController(testrig.NewMockHTTPClient(func(req *http.Request) (*http.Response, error) { + r := ioutil.NopCloser(bytes.NewReader([]byte(responseBodyString))) + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + })) + // get this transport controller embedded right in the user module we're testing + federator := testrig.NewTestFederator(suite.db, tc) + processor := testrig.NewTestProcessor(suite.db, suite.storage, federator) + userModule := user.New(suite.config, processor, suite.log).(*user.Module) + + // setup request + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:8080%s", strings.Replace(user.UsersBasePathWithUsername, ":username", targetAccount.Username, 1)), nil) // the endpoint we're hitting + + // normally the router would populate these params from the path values, + // but because we're calling the function directly, we need to set them manually. + ctx.Params = gin.Params{ + gin.Param{ + Key: user.UsernameKey, + Value: targetAccount.Username, + }, + } + + // we need these headers for the request to be validated + ctx.Request.Header.Set("Signature", signedRequest.SignatureHeader) + ctx.Request.Header.Set("Date", signedRequest.DateHeader) + ctx.Request.Header.Set("Digest", signedRequest.DigestHeader) + + // trigger the function being tested + userModule.UsersGETHandler(ctx) + + // check response + suite.EqualValues(http.StatusOK, recorder.Code) + + result := recorder.Result() + defer result.Body.Close() + b, err := ioutil.ReadAll(result.Body) + assert.NoError(suite.T(), err) + + // should be a Person + m := make(map[string]interface{}) + err = json.Unmarshal(b, &m) + assert.NoError(suite.T(), err) + + t, err := streams.ToType(context.Background(), m) + assert.NoError(suite.T(), err) + + person, ok := t.(vocab.ActivityStreamsPerson) + assert.True(suite.T(), ok) + + // convert person to account + // since this account is already known, we should get a pretty full model of it from the conversion + a, err := suite.tc.ASPersonToAccount(person) + assert.NoError(suite.T(), err) + assert.EqualValues(suite.T(), targetAccount.Username, a.Username) +} + +func TestUserGetTestSuite(t *testing.T) { + suite.Run(t, new(UserGetTestSuite)) +} diff --git a/internal/federation/util.go b/internal/federation/util.go index 493335df4..0467892f8 100644 --- a/internal/federation/util.go +++ b/internal/federation/util.go @@ -157,6 +157,9 @@ func (f *federator) AuthenticateFederatedRequest(username string, r *http.Reques if err != nil { return nil, fmt.Errorf("could not parse public key from block bytes: %s", err) } + if p == nil { + return nil, errors.New("returned public key was empty") + } // do the actual authentication here! algo := httpsig.RSA_SHA256 // TODO: make this more robust diff --git a/internal/gtsmodel/mediaattachment.go b/internal/gtsmodel/mediaattachment.go index 751956252..e98602842 100644 --- a/internal/gtsmodel/mediaattachment.go +++ b/internal/gtsmodel/mediaattachment.go @@ -108,15 +108,15 @@ type FileType string const ( // FileTypeImage is for jpegs and pngs - FileTypeImage FileType = "image" + FileTypeImage FileType = "Image" // FileTypeGif is for native gifs and soundless videos that have been converted to gifs - FileTypeGif FileType = "gif" + FileTypeGif FileType = "Gif" // FileTypeAudio is for audio-only files (no video) - FileTypeAudio FileType = "audio" + FileTypeAudio FileType = "Audio" // FileTypeVideo is for files with audio + visual - FileTypeVideo FileType = "video" + FileTypeVideo FileType = "Video" // FileTypeUnknown is for unknown file types (surprise surprise!) - FileTypeUnknown FileType = "unknown" + FileTypeUnknown FileType = "Unknown" ) // FileMeta describes metadata about the actual contents of the file. diff --git a/internal/media/media.go b/internal/media/media.go index b87ffac5b..638b120a2 100644 --- a/internal/media/media.go +++ b/internal/media/media.go @@ -113,7 +113,7 @@ func (mh *mediaHandler) ProcessHeaderOrAvatar(attachment []byte, accountID strin if err != nil { return nil, err } - if !supportedImageType(contentType) { + if !SupportedImageType(contentType) { return nil, fmt.Errorf("%s is not an accepted image type", contentType) } @@ -146,8 +146,8 @@ func (mh *mediaHandler) ProcessLocalAttachment(attachment []byte, accountID stri } mainType := strings.Split(contentType, "/")[0] switch mainType { - case "video": - if !supportedVideoType(contentType) { + case MIMEVideo: + if !SupportedVideoType(contentType) { return nil, fmt.Errorf("video type %s not supported", contentType) } if len(attachment) == 0 { @@ -157,8 +157,8 @@ func (mh *mediaHandler) ProcessLocalAttachment(attachment []byte, accountID stri return nil, fmt.Errorf("video size %d bytes exceeded max video size of %d bytes", len(attachment), mh.config.MediaConfig.MaxVideoSize) } return mh.processVideoAttachment(attachment, accountID, contentType) - case "image": - if !supportedImageType(contentType) { + case MIMEImage: + if !SupportedImageType(contentType) { return nil, fmt.Errorf("image type %s not supported", contentType) } if len(attachment) == 0 { @@ -199,13 +199,13 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) ( return nil, fmt.Errorf("emoji size %d bytes exceeded max emoji size of %d bytes", len(emojiBytes), EmojiMaxBytes) } - // clean any exif data from image/png type but leave gifs alone + // clean any exif data from png but leave gifs alone switch contentType { - case "image/png": + case MIMEPng: if clean, err = purgeExif(emojiBytes); err != nil { return nil, fmt.Errorf("error cleaning exif data: %s", err) } - case "image/gif": + case MIMEGif: clean = emojiBytes default: return nil, errors.New("media type unrecognized") @@ -275,7 +275,7 @@ func (mh *mediaHandler) ProcessLocalEmoji(emojiBytes []byte, shortcode string) ( ImagePath: emojiPath, ImageStaticPath: emojiStaticPath, ImageContentType: contentType, - ImageStaticContentType: "image/png", // static version will always be a png + ImageStaticContentType: MIMEPng, // static version will always be a png ImageFileSize: len(original.image), ImageStaticFileSize: len(static.image), ImageUpdatedAt: time.Now(), @@ -302,7 +302,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co var small *imageAndMeta switch contentType { - case "image/jpeg", "image/png": + case MIMEJpeg, MIMEPng: if clean, err = purgeExif(data); err != nil { return nil, fmt.Errorf("error cleaning exif data: %s", err) } @@ -310,7 +310,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co if err != nil { return nil, fmt.Errorf("error parsing image: %s", err) } - case "image/gif": + case MIMEGif: clean = data original, err = deriveGif(clean, contentType) if err != nil { @@ -380,7 +380,7 @@ func (mh *mediaHandler) processImageAttachment(data []byte, accountID string, co }, Thumbnail: gtsmodel.Thumbnail{ Path: smallPath, - ContentType: "image/jpeg", // all thumbnails/smalls are encoded as jpeg + ContentType: MIMEJpeg, // all thumbnails/smalls are encoded as jpeg FileSize: len(small.image), UpdatedAt: time.Now(), URL: smallURL, @@ -411,15 +411,15 @@ func (mh *mediaHandler) processHeaderOrAvi(imageBytes []byte, contentType string var err error switch contentType { - case "image/jpeg": + case MIMEJpeg: if clean, err = purgeExif(imageBytes); err != nil { return nil, fmt.Errorf("error cleaning exif data: %s", err) } - case "image/png": + case MIMEPng: if clean, err = purgeExif(imageBytes); err != nil { return nil, fmt.Errorf("error cleaning exif data: %s", err) } - case "image/gif": + case MIMEGif: clean = imageBytes default: return nil, errors.New("media type unrecognized") diff --git a/internal/media/util.go b/internal/media/util.go index c194996c3..6e5756202 100644 --- a/internal/media/util.go +++ b/internal/media/util.go @@ -33,6 +33,26 @@ import ( "github.com/superseriousbusiness/exifremove/pkg/exifremove" ) +const ( + // MIMEImage is the mime type for image + MIMEImage = "image" + // MIMEJpeg is the jpeg image mime type + MIMEJpeg = "image/jpeg" + // MIMEGif is the gif image mime type + MIMEGif = "image/gif" + // MIMEPng is the png image mime type + MIMEPng = "image/png" + + // MIMEVideo is the mime type for video + MIMEVideo = "video" + // MIMEmp4 is the mp4 video mime type + MIMEMp4 = "video/mp4" + // MIMEMpeg is the mpeg video mime type + MIMEMpeg = "video/mpeg" + // MIMEWebm is the webm video mime type + MIMEWebm = "video/webm" +) + // parseContentType parses the MIME content type from a file, returning it as a string in the form (eg., "image/jpeg"). // Returns an error if the content type is not something we can process. func parseContentType(content []byte) (string, error) { @@ -54,13 +74,13 @@ func parseContentType(content []byte) (string, error) { return kind.MIME.Value, nil } -// supportedImageType checks mime type of an image against a slice of accepted types, +// SupportedImageType checks mime type of an image against a slice of accepted types, // and returns True if the mime type is accepted. -func supportedImageType(mimeType string) bool { +func SupportedImageType(mimeType string) bool { acceptedImageTypes := []string{ - "image/jpeg", - "image/gif", - "image/png", + MIMEJpeg, + MIMEGif, + MIMEPng, } for _, accepted := range acceptedImageTypes { if mimeType == accepted { @@ -70,13 +90,13 @@ func supportedImageType(mimeType string) bool { return false } -// supportedVideoType checks mime type of a video against a slice of accepted types, +// 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 { +func SupportedVideoType(mimeType string) bool { acceptedVideoTypes := []string{ - "video/mp4", - "video/mpeg", - "video/webm", + MIMEMp4, + MIMEMpeg, + MIMEWebm, } for _, accepted := range acceptedVideoTypes { if mimeType == accepted { @@ -89,8 +109,8 @@ func supportedVideoType(mimeType string) bool { // 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", + MIMEGif, + MIMEPng, } for _, accepted := range acceptedEmojiTypes { if mimeType == accepted { @@ -121,7 +141,7 @@ func deriveGif(b []byte, extension string) (*imageAndMeta, error) { var g *gif.GIF var err error switch extension { - case "image/gif": + case MIMEGif: g, err = gif.DecodeAll(bytes.NewReader(b)) if err != nil { return nil, err @@ -161,12 +181,12 @@ func deriveImage(b []byte, contentType string) (*imageAndMeta, error) { var err error switch contentType { - case "image/jpeg": + case MIMEJpeg: i, err = jpeg.Decode(bytes.NewReader(b)) if err != nil { return nil, err } - case "image/png": + case MIMEPng: i, err = png.Decode(bytes.NewReader(b)) if err != nil { return nil, err @@ -210,17 +230,17 @@ func deriveThumbnail(b []byte, contentType string, x uint, y uint) (*imageAndMet var err error switch contentType { - case "image/jpeg": + case MIMEJpeg: i, err = jpeg.Decode(bytes.NewReader(b)) if err != nil { return nil, err } - case "image/png": + case MIMEPng: i, err = png.Decode(bytes.NewReader(b)) if err != nil { return nil, err } - case "image/gif": + case MIMEGif: i, err = gif.Decode(bytes.NewReader(b)) if err != nil { return nil, err @@ -254,12 +274,12 @@ func deriveStaticEmoji(b []byte, contentType string) (*imageAndMeta, error) { var err error switch contentType { - case "image/png": + case MIMEPng: i, err = png.Decode(bytes.NewReader(b)) if err != nil { return nil, err } - case "image/gif": + case MIMEGif: i, err = gif.Decode(bytes.NewReader(b)) if err != nil { return nil, err diff --git a/internal/media/util_test.go b/internal/media/util_test.go index be617a256..db2cca690 100644 --- a/internal/media/util_test.go +++ b/internal/media/util_test.go @@ -135,10 +135,10 @@ func (suite *MediaUtilTestSuite) TestDeriveThumbnailFromJPEG() { } func (suite *MediaUtilTestSuite) TestSupportedImageTypes() { - ok := supportedImageType("image/jpeg") + ok := SupportedImageType("image/jpeg") assert.True(suite.T(), ok) - ok = supportedImageType("image/bmp") + ok = SupportedImageType("image/bmp") assert.False(suite.T(), ok) } diff --git a/internal/typeutils/astointernal.go b/internal/typeutils/astointernal.go index a691a21ad..73809c57b 100644 --- a/internal/typeutils/astointernal.go +++ b/internal/typeutils/astointernal.go @@ -1,71 +1,197 @@ package typeutils import ( + "errors" + "fmt" + "net/url" + "github.com/go-fed/activity/streams/vocab" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/media" ) func (c *converter) ASPersonToAccount(person vocab.ActivityStreamsPerson) (*gtsmodel.Account, error) { + // first check if we actually already know this person + uriProp := person.GetJSONLDId() + if uriProp == nil || !uriProp.IsIRI() { + return nil, errors.New("no id property found on person, or id was not an iri") + } + uri := uriProp.GetIRI() - // acct := >smodel.Account{ - // URI: "", - // URL: "", - // ID: "", - // Username: "", - // Domain: "", - // AvatarMediaAttachmentID: "", - // AvatarRemoteURL: "", - // HeaderMediaAttachmentID: "", - // HeaderRemoteURL: "", - // DisplayName: "", - // Fields: nil, - // Note: "", - // Memorial: false, - // MovedToAccountID: "", - // CreatedAt: time.Time{}, - // UpdatedAt: time.Time{}, - // Bot: false, - // Reason: "", - // Locked: false, - // Discoverable: true, - // Privacy: "", - // Sensitive: false, - // Language: "", - // LastWebfingeredAt: time.Now(), - // InboxURI: "", - // OutboxURI: "", - // FollowingURI: "", - // FollowersURI: "", - // FeaturedCollectionURI: "", - // ActorType: gtsmodel.ActivityStreamsPerson, - // AlsoKnownAs: "", - // PrivateKey: nil, - // PublicKey: nil, - // PublicKeyURI: "", - // SensitizedAt: time.Time{}, - // SilencedAt: time.Time{}, - // SuspendedAt: time.Time{}, - // HideCollections: false, - // SuspensionOrigin: "", - // } + acct := >smodel.Account{} + if err := c.db.GetWhere("uri", uri.String(), acct); err == nil { + // we already know this account so we can skip generating it + return acct, nil + } else { + if _, ok := err.(db.ErrNoEntries); !ok { + // we don't know the account and there's been a real error + return nil, fmt.Errorf("error getting account with uri %s from the database: %s", uri.String(), err) + } + } - // // ID - // // Generate a new uuid for our particular database. - // // This is distinct from the AP ID of the person. - // id := uuid.NewString() - // acct.ID = id + // we don't know the account so we need to generate it from the person -- at least we already have the URI! + acct = >smodel.Account{} + acct.URI = uri.String() - // // Username - // // We need this one so bail if it's not set. - // username := person.GetActivityStreamsPreferredUsername() - // if username == nil || username.GetXMLSchemaString() == "" { - // return nil, errors.New("preferredusername was empty") - // } - // acct.Username = username.GetXMLSchemaString() + // Username + // We need this one so bail if it's not set. + username, err := extractUsername(person) + if err != nil { + return nil, fmt.Errorf("couldn't extract username: %s", err) + } + acct.Username = username - // // Domain - // // We need this one as well - // acct.Domain = domain + // Domain + // We need this one as well + acct.Domain = uri.Host - return nil, nil + // avatar aka icon + // if this one isn't extractable in a format we recognise we'll just skip it + if avatarURL, err := extractIconURL(person); err == nil { + acct.AvatarRemoteURL = avatarURL.String() + } + + // header aka image + // if this one isn't extractable in a format we recognise we'll just skip it + if headerURL, err := extractImageURL(person); err == nil { + acct.HeaderRemoteURL = headerURL.String() + } + + return acct, nil +} + +type usernameable interface { + GetActivityStreamsPreferredUsername() vocab.ActivityStreamsPreferredUsernameProperty +} + +func extractUsername(i usernameable) (string, error) { + u := i.GetActivityStreamsPreferredUsername() + if u == nil || !u.IsXMLSchemaString() { + return "", errors.New("preferredUsername was not a string") + } + if u.GetXMLSchemaString() == "" { + return "", errors.New("preferredUsername was empty") + } + return u.GetXMLSchemaString(), nil +} + +type iconable interface { + GetActivityStreamsIcon() vocab.ActivityStreamsIconProperty +} + +// extractIconURL extracts a URL to a supported image file from something like: +// "icon": { +// "mediaType": "image/jpeg", +// "type": "Image", +// "url": "http://example.org/path/to/some/file.jpeg" +// }, +func extractIconURL(i iconable) (*url.URL, error) { + iconProp := i.GetActivityStreamsIcon() + if iconProp == nil { + return nil, errors.New("icon property was nil") + } + + // icon can potentially contain multiple entries, so we iterate through all of them + // here in order to find the first one that meets these criteria: + // 1. is an image + // 2. is a supported type + // 3. has a URL so we can grab it + for iconIter := iconProp.Begin(); iconIter != iconProp.End(); iconIter = iconIter.Next() { + // 1. is an image + if !iconIter.IsActivityStreamsImage() { + continue + } + imageValue := iconIter.GetActivityStreamsImage() + if imageValue == nil { + continue + } + + // 2. is a supported type + imageType := imageValue.GetActivityStreamsMediaType() + if imageType == nil || !media.SupportedImageType(imageType.Get()) { + continue + } + + // 3. has a URL so we can grab it + imageURLProp := imageValue.GetActivityStreamsUrl() + if imageURLProp == nil { + continue + } + + // URL is also an iterable! + // so let's take the first valid one we can find + for urlIter := imageURLProp.Begin(); urlIter != imageURLProp.End(); urlIter = urlIter.Next() { + if !urlIter.IsIRI() { + continue + } + if urlIter.GetIRI() == nil { + continue + } + // found it!!! + return urlIter.GetIRI(), nil + } + } + // if we get to this point we didn't find an icon meeting our criteria :'( + return nil, errors.New("could not extract valid image from icon") +} + +type imageable interface { + GetActivityStreamsImage() vocab.ActivityStreamsImageProperty +} + +// extractImageURL extracts a URL to a supported image file from something like: +// "image": { +// "mediaType": "image/jpeg", +// "type": "Image", +// "url": "http://example.org/path/to/some/file.jpeg" +// }, +func extractImageURL(i imageable) (*url.URL, error) { + imageProp := i.GetActivityStreamsImage() + if imageProp == nil { + return nil, errors.New("icon property was nil") + } + + // icon can potentially contain multiple entries, so we iterate through all of them + // here in order to find the first one that meets these criteria: + // 1. is an image + // 2. is a supported type + // 3. has a URL so we can grab it + for imageIter := imageProp.Begin(); imageIter != imageProp.End(); imageIter = imageIter.Next() { + // 1. is an image + if !imageIter.IsActivityStreamsImage() { + continue + } + imageValue := imageIter.GetActivityStreamsImage() + if imageValue == nil { + continue + } + + // 2. is a supported type + imageType := imageValue.GetActivityStreamsMediaType() + if imageType == nil || !media.SupportedImageType(imageType.Get()) { + continue + } + + // 3. has a URL so we can grab it + imageURLProp := imageValue.GetActivityStreamsUrl() + if imageURLProp == nil { + continue + } + + // URL is also an iterable! + // so let's take the first valid one we can find + for urlIter := imageURLProp.Begin(); urlIter != imageURLProp.End(); urlIter = urlIter.Next() { + if !urlIter.IsIRI() { + continue + } + if urlIter.GetIRI() == nil { + continue + } + // found it!!! + return urlIter.GetIRI(), nil + } + } + // if we get to this point we didn't find an image meeting our criteria :'( + return nil, errors.New("could not extract valid image from image property") } diff --git a/testrig/testmodels.go b/testrig/testmodels.go index 6427630c7..42ce3f13b 100644 --- a/testrig/testmodels.go +++ b/testrig/testmodels.go @@ -440,14 +440,14 @@ func NewTestAccounts() map[string]*gtsmodel.Account { Discoverable: true, Sensitive: false, Language: "en", - URI: "https://fossbros-anonymous.io/users/foss_satan", - URL: "https://fossbros-anonymous.io/@foss_satan", + URI: "http://fossbros-anonymous.io/users/foss_satan", + URL: "http://fossbros-anonymous.io/@foss_satan", LastWebfingeredAt: time.Time{}, - InboxURI: "https://fossbros-anonymous.io/users/foss_satan/inbox", - OutboxURI: "https://fossbros-anonymous.io/users/foss_satan/outbox", - FollowersURI: "https://fossbros-anonymous.io/users/foss_satan/followers", - FollowingURI: "https://fossbros-anonymous.io/users/foss_satan/following", - FeaturedCollectionURI: "https://fossbros-anonymous.io/users/foss_satan/collections/featured", + InboxURI: "http://fossbros-anonymous.io/users/foss_satan/inbox", + OutboxURI: "http://fossbros-anonymous.io/users/foss_satan/outbox", + FollowersURI: "http://fossbros-anonymous.io/users/foss_satan/followers", + FollowingURI: "http://fossbros-anonymous.io/users/foss_satan/following", + FeaturedCollectionURI: "http://fossbros-anonymous.io/users/foss_satan/collections/featured", ActorType: gtsmodel.ActivityStreamsPerson, AlsoKnownAs: "", PrivateKey: nil, @@ -1047,12 +1047,20 @@ func NewTestActivities(accounts map[string]*gtsmodel.Account) map[string]Activit } } +func NewTestDereferenceRequests(accounts map[string]*gtsmodel.Account) map[string]ActivityWithSignature { + sig, digest, date := getSignatureForDereference(accounts["remote_account_1"].PublicKeyURI, accounts["remote_account_1"].PrivateKey, URLMustParse(accounts["local_account_1"].URI)) + return map[string]ActivityWithSignature{ + "foss_satan_dereference_zork": { + SignatureHeader: sig, + DigestHeader: digest, + DateHeader: date, + }, + } +} + // getSignatureForActivity does some sneaky sneaky work with a mock http client and a test transport controller, in order to derive // the HTTP Signature for the given activity, public key ID, private key, and destination. func getSignatureForActivity(activity pub.Activity, pubKeyID string, privkey crypto.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) { - - streams.NewActivityStreamsPerson() - // create a client that basically just pulls the signature out of the request and sets it client := &mockHTTPClient{ do: func(req *http.Request) (*http.Response, error) { @@ -1093,6 +1101,39 @@ func getSignatureForActivity(activity pub.Activity, pubKeyID string, privkey cry return } +// getSignatureForDereference does some sneaky sneaky work with a mock http client and a test transport controller, in order to derive +// the HTTP Signature for the given derefence GET request using public key ID, private key, and destination. +func getSignatureForDereference(pubKeyID string, privkey crypto.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) { + // create a client that basically just pulls the signature out of the request and sets it + client := &mockHTTPClient{ + do: func(req *http.Request) (*http.Response, error) { + signatureHeader = req.Header.Get("Signature") + digestHeader = req.Header.Get("Digest") + dateHeader = req.Header.Get("Date") + r := ioutil.NopCloser(bytes.NewReader([]byte{})) // we only need this so the 'close' func doesn't nil out + return &http.Response{ + StatusCode: 200, + Body: r, + }, nil + }, + } + + // use the client to create a new transport + c := NewTestTransportController(client) + tp, err := c.NewTransport(pubKeyID, privkey) + if err != nil { + panic(err) + } + + // trigger the delivery function, which will trigger the 'do' function of the recorder above + if _, err := tp.Dereference(context.Background(), destination); err != nil { + panic(err) + } + + // headers should now be populated + return +} + // newNote returns a new activity streams note for the given parameters func newNote( noteID *url.URL,