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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 literal 0 HcmV?d00001