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 0000000000..dfd2125edc Binary files /dev/null and b/tests/integration/avatar.png differ