From 254a82842addb1475611789107c3720e37394879 Mon Sep 17 00:00:00 2001 From: JakobDev Date: Fri, 30 Jun 2023 01:22:55 +0200 Subject: [PATCH] Add API for changing Avatars (#25369) This adds an API for uploading and Deleting Avatars for of Users, Repos and Organisations. I'm not sure, if this should also be added to the Admin API. Resolves #25344 --------- Co-authored-by: silverwind Co-authored-by: Giteabot --- modules/structs/repo.go | 6 + modules/structs/user.go | 6 + routers/api/v1/api.go | 13 ++ routers/api/v1/org/avatar.go | 74 ++++++++ routers/api/v1/repo/avatar.go | 84 ++++++++++ routers/api/v1/swagger/options.go | 6 + routers/api/v1/user/avatar.go | 63 +++++++ templates/swagger/v1_json.tmpl | 195 +++++++++++++++++++++- tests/integration/api_org_avatar_test.go | 72 ++++++++ tests/integration/api_repo_avatar_test.go | 76 +++++++++ tests/integration/api_user_avatar_test.go | 72 ++++++++ tests/integration/avatar.png | Bin 0 -> 7787 bytes 12 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 routers/api/v1/org/avatar.go create mode 100644 routers/api/v1/repo/avatar.go create mode 100644 routers/api/v1/user/avatar.go create mode 100644 tests/integration/api_org_avatar_test.go create mode 100644 tests/integration/api_repo_avatar_test.go create mode 100644 tests/integration/api_user_avatar_test.go create mode 100644 tests/integration/avatar.png diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 3b43f74c79..94992de72e 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -380,3 +380,9 @@ type NewIssuePinsAllowed struct { Issues bool `json:"issues"` PullRequests bool `json:"pull_requests"` } + +// UpdateRepoAvatarUserOption options when updating the repo avatar +type UpdateRepoAvatarOption struct { + // image must be base64 encoded + Image string `json:"image" binding:"Required"` +} diff --git a/modules/structs/user.go b/modules/structs/user.go index f68b92ac06..0df67894b0 100644 --- a/modules/structs/user.go +++ b/modules/structs/user.go @@ -102,3 +102,9 @@ type RenameUserOption struct { // unique: true NewName string `json:"new_username" binding:"Required"` } + +// UpdateUserAvatarUserOption options when updating the user avatar +type UpdateUserAvatarOption struct { + // image must be base64 encoded + Image string `json:"image" binding:"Required"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 8b7f55976b..0e28bde683 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -899,6 +899,11 @@ func Routes() *web.Route { Patch(bind(api.EditHookOption{}), user.EditHook). Delete(user.DeleteHook) }, reqWebhooksEnabled()) + + m.Group("/avatar", func() { + m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar) + m.Delete("", user.DeleteAvatar) + }, reqToken()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) // Repositories (requires repo scope, org scope) @@ -1134,6 +1139,10 @@ func Routes() *web.Route { m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages) m.Get("/activities/feeds", repo.ListRepoActivityFeeds) m.Get("/new_pin_allowed", repo.AreNewIssuePinsAllowed) + m.Group("/avatar", func() { + m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar) + m.Delete("", repo.DeleteAvatar) + }, reqAdmin(), reqToken()) }, repoAssignment()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) @@ -1314,6 +1323,10 @@ func Routes() *web.Route { Patch(bind(api.EditHookOption{}), org.EditHook). Delete(org.DeleteHook) }, reqToken(), reqOrgOwnership(), reqWebhooksEnabled()) + m.Group("/avatar", func() { + m.Post("", bind(api.UpdateUserAvatarOption{}), org.UpdateAvatar) + m.Delete("", org.DeleteAvatar) + }, reqToken(), reqOrgOwnership()) m.Get("/activities/feeds", org.ListOrgActivityFeeds) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true)) m.Group("/teams/{teamid}", func() { diff --git a/routers/api/v1/org/avatar.go b/routers/api/v1/org/avatar.go new file mode 100644 index 0000000000..b3cb0b81a6 --- /dev/null +++ b/routers/api/v1/org/avatar.go @@ -0,0 +1,74 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "encoding/base64" + "net/http" + + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + user_service "code.gitea.io/gitea/services/user" +) + +// UpdateAvatarupdates the Avatar of an Organisation +func UpdateAvatar(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/avatar organization orgUpdateAvatar + // --- + // summary: Update Avatar + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateUserAvatarOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + form := web.GetForm(ctx).(*api.UpdateUserAvatarOption) + + content, err := base64.StdEncoding.DecodeString(form.Image) + if err != nil { + ctx.Error(http.StatusBadRequest, "DecodeImage", err) + return + } + + err = user_service.UploadAvatar(ctx.Org.Organization.AsUser(), content) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteAvatar deletes the Avatar of an Organisation +func DeleteAvatar(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/avatar organization orgDeleteAvatar + // --- + // summary: Delete Avatar + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + err := user_service.DeleteAvatar(ctx.Org.Organization.AsUser()) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/avatar.go b/routers/api/v1/repo/avatar.go new file mode 100644 index 0000000000..48bd143d0c --- /dev/null +++ b/routers/api/v1/repo/avatar.go @@ -0,0 +1,84 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "encoding/base64" + "net/http" + + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + repo_service "code.gitea.io/gitea/services/repository" +) + +// UpdateVatar updates the Avatar of an Repo +func UpdateAvatar(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/avatar repository repoUpdateAvatar + // --- + // summary: Update avatar + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateRepoAvatarOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + form := web.GetForm(ctx).(*api.UpdateRepoAvatarOption) + + content, err := base64.StdEncoding.DecodeString(form.Image) + if err != nil { + ctx.Error(http.StatusBadRequest, "DecodeImage", err) + return + } + + err = repo_service.UploadAvatar(ctx, ctx.Repo.Repository, content) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateAvatar deletes the Avatar of an Repo +func DeleteAvatar(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/avatar repository repoDeleteAvatar + // --- + // summary: Delete avatar + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + err := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 353d32e214..073d9a19f7 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -181,4 +181,10 @@ type swaggerParameterBodies struct { // in:body CreatePushMirrorOption api.CreatePushMirrorOption + + // in:body + UpdateUserAvatarOptions api.UpdateUserAvatarOption + + // in:body + UpdateRepoAvatarOptions api.UpdateRepoAvatarOption } diff --git a/routers/api/v1/user/avatar.go b/routers/api/v1/user/avatar.go new file mode 100644 index 0000000000..84fa129b13 --- /dev/null +++ b/routers/api/v1/user/avatar.go @@ -0,0 +1,63 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "encoding/base64" + "net/http" + + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + user_service "code.gitea.io/gitea/services/user" +) + +// UpdateAvatar updates the Avatar of an User +func UpdateAvatar(ctx *context.APIContext) { + // swagger:operation POST /user/avatar user userUpdateAvatar + // --- + // summary: Update Avatar + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateUserAvatarOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + form := web.GetForm(ctx).(*api.UpdateUserAvatarOption) + + content, err := base64.StdEncoding.DecodeString(form.Image) + if err != nil { + ctx.Error(http.StatusBadRequest, "DecodeImage", err) + return + } + + err = user_service.UploadAvatar(ctx.Doer, content) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteAvatar deletes the Avatar of an User +func DeleteAvatar(ctx *context.APIContext) { + // swagger:operation DELETE /user/avatar user userDeleteAvatar + // --- + // summary: Delete Avatar + // produces: + // - application/json + // responses: + // "204": + // "$ref": "#/responses/empty" + err := user_service.DeleteAvatar(ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 11abeac77c..f98dc12d95 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1595,6 +1595,63 @@ } } }, + "/orgs/{org}/avatar": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Update Avatar", + "operationId": "orgUpdateAvatar", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateUserAvatarOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Delete Avatar", + "operationId": "orgDeleteAvatar", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, "/orgs/{org}/hooks": { "get": { "produces": [ @@ -3174,6 +3231,77 @@ } } }, + "/repos/{owner}/{repo}/avatar": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Update avatar", + "operationId": "repoUpdateAvatar", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateRepoAvatarOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Delete avatar", + "operationId": "repoDeleteAvatar", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, "/repos/{owner}/{repo}/branch_protections": { "get": { "produces": [ @@ -13787,6 +13915,47 @@ } } }, + "/user/avatar": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update Avatar", + "operationId": "userUpdateAvatar", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateUserAvatarOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete Avatar", + "operationId": "userDeleteAvatar", + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, "/user/emails": { "get": { "produces": [ @@ -21548,6 +21717,30 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "UpdateRepoAvatarOption": { + "description": "UpdateRepoAvatarUserOption options when updating the repo avatar", + "type": "object", + "properties": { + "image": { + "description": "image must be base64 encoded", + "type": "string", + "x-go-name": "Image" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "UpdateUserAvatarOption": { + "description": "UpdateUserAvatarUserOption options when updating the user avatar", + "type": "object", + "properties": { + "image": { + "description": "image must be base64 encoded", + "type": "string", + "x-go-name": "Image" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "User": { "description": "User represents a user", "type": "object", @@ -22837,7 +23030,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/CreatePushMirrorOption" + "$ref": "#/definitions/UpdateRepoAvatarOption" } }, "redirect": { diff --git a/tests/integration/api_org_avatar_test.go b/tests/integration/api_org_avatar_test.go new file mode 100644 index 0000000000..e0a4150e9f --- /dev/null +++ b/tests/integration/api_org_avatar_test.go @@ -0,0 +1,72 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "net/http" + "os" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUpdateOrgAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + + // Test what happens if you use a valid image + avatar, err := os.ReadFile("tests/integration/avatar.png") + assert.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open avatar.png") + } + + opts := api.UpdateUserAvatarOption{ + Image: base64.StdEncoding.EncodeToString(avatar), + } + + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/user3/avatar?token="+token, &opts) + MakeRequest(t, req, http.StatusNoContent) + + // Test what happens if you don't have a valid Base64 string + opts = api.UpdateUserAvatarOption{ + Image: "Invalid", + } + + req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/user3/avatar?token="+token, &opts) + MakeRequest(t, req, http.StatusBadRequest) + + // Test what happens if you use a file that is not an image + text, err := os.ReadFile("tests/integration/README.md") + assert.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open README.md") + } + + opts = api.UpdateUserAvatarOption{ + Image: base64.StdEncoding.EncodeToString(text), + } + + req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/user3/avatar?token="+token, &opts) + MakeRequest(t, req, http.StatusInternalServerError) +} + +func TestAPIDeleteOrgAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + + req := NewRequest(t, "DELETE", "/api/v1/orgs/user3/avatar?token="+token) + MakeRequest(t, req, http.StatusNoContent) +} diff --git a/tests/integration/api_repo_avatar_test.go b/tests/integration/api_repo_avatar_test.go new file mode 100644 index 0000000000..58a4fc536c --- /dev/null +++ b/tests/integration/api_repo_avatar_test.go @@ -0,0 +1,76 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "fmt" + "net/http" + "os" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUpdateRepoAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeWriteRepository) + + // Test what happens if you use a valid image + avatar, err := os.ReadFile("tests/integration/avatar.png") + assert.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open avatar.png") + } + + opts := api.UpdateRepoAvatarOption{ + Image: base64.StdEncoding.EncodeToString(avatar), + } + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar?token=%s", repo.OwnerName, repo.Name, token), &opts) + MakeRequest(t, req, http.StatusNoContent) + + // Test what happens if you don't have a valid Base64 string + opts = api.UpdateRepoAvatarOption{ + Image: "Invalid", + } + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar?token=%s", repo.OwnerName, repo.Name, token), &opts) + MakeRequest(t, req, http.StatusBadRequest) + + // Test what happens if you use a file that is not an image + text, err := os.ReadFile("tests/integration/README.md") + assert.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open README.md") + } + + opts = api.UpdateRepoAvatarOption{ + Image: base64.StdEncoding.EncodeToString(text), + } + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar?token=%s", repo.OwnerName, repo.Name, token), &opts) + MakeRequest(t, req, http.StatusInternalServerError) +} + +func TestAPIDeleteRepoAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/avatar?token=%s", repo.OwnerName, repo.Name, token)) + MakeRequest(t, req, http.StatusNoContent) +} diff --git a/tests/integration/api_user_avatar_test.go b/tests/integration/api_user_avatar_test.go new file mode 100644 index 0000000000..807c119e2c --- /dev/null +++ b/tests/integration/api_user_avatar_test.go @@ -0,0 +1,72 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "net/http" + "os" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUpdateUserAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + session := loginUser(t, normalUsername) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + // Test what happens if you use a valid image + avatar, err := os.ReadFile("tests/integration/avatar.png") + assert.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open avatar.png") + } + + // Test what happens if you don't have a valid Base64 string + opts := api.UpdateUserAvatarOption{ + Image: base64.StdEncoding.EncodeToString(avatar), + } + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/avatar?token="+token, &opts) + MakeRequest(t, req, http.StatusNoContent) + + opts = api.UpdateUserAvatarOption{ + Image: "Invalid", + } + + req = NewRequestWithJSON(t, "POST", "/api/v1/user/avatar?token="+token, &opts) + MakeRequest(t, req, http.StatusBadRequest) + + // Test what happens if you use a file that is not an image + text, err := os.ReadFile("tests/integration/README.md") + assert.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open README.md") + } + + opts = api.UpdateUserAvatarOption{ + Image: base64.StdEncoding.EncodeToString(text), + } + + req = NewRequestWithJSON(t, "POST", "/api/v1/user/avatar?token="+token, &opts) + MakeRequest(t, req, http.StatusInternalServerError) +} + +func TestAPIDeleteUserAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + session := loginUser(t, normalUsername) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + req := NewRequest(t, "DELETE", "/api/v1/user/avatar?token="+token) + MakeRequest(t, req, http.StatusNoContent) +} diff --git a/tests/integration/avatar.png b/tests/integration/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..dfd2125edc523f52181aade3ab93cfbadb565e0b GIT binary patch literal 7787 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 E05&-YzW@LL literal 0 HcmV?d00001