From 2173f14708ff3b35d7821fc9b6dcb5fcd06b8494 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Fri, 10 Mar 2023 15:28:32 +0100 Subject: [PATCH 01/13] Add user webhooks (#21563) Currently we can add webhooks for organizations but not for users. This PR adds the latter. You can access it from the current users settings. ![grafik](https://user-images.githubusercontent.com/1666336/197391408-15dfdc23-b476-4d0c-82f7-9bc9b065988f.png) --- .../doc/developers/oauth2-provider.en-us.md | 1 + models/auth/token_scope.go | 10 +- models/auth/token_scope_test.go | 4 +- models/fixtures/webhook.yml | 2 +- models/migrations/migrations.go | 2 + models/migrations/v1_20/v245.go | 74 +++++++++ models/webhook/webhook.go | 24 +-- models/webhook/webhook_system.go | 10 +- models/webhook/webhook_test.go | 24 +-- options/locale/locale_en-US.ini | 2 + routers/api/v1/admin/hooks.go | 5 +- routers/api/v1/api.go | 7 + routers/api/v1/org/hook.go | 79 +++------ routers/api/v1/repo/hook.go | 6 +- routers/api/v1/user/hook.go | 154 ++++++++++++++++++ routers/api/v1/utils/hook.go | 89 +++++++--- routers/web/org/setting.go | 8 +- routers/web/repo/webhook.go | 67 ++++---- routers/web/user/setting/webhooks.go | 48 ++++++ routers/web/web.go | 123 ++++++-------- services/repository/hooks.go | 2 +- services/webhook/webhook.go | 10 +- templates/repo/settings/webhook/history.tmpl | 2 +- templates/swagger/v1_json.tmpl | 146 +++++++++++++++++ templates/user/settings/applications.tmpl | 6 + templates/user/settings/hook_new.tmpl | 53 ++++++ templates/user/settings/hooks.tmpl | 8 + templates/user/settings/navbar.tmpl | 5 + 28 files changed, 737 insertions(+), 234 deletions(-) create mode 100644 models/migrations/v1_20/v245.go create mode 100644 routers/api/v1/user/hook.go create mode 100644 routers/web/user/setting/webhooks.go create mode 100644 templates/user/settings/hook_new.tmpl create mode 100644 templates/user/settings/hooks.tmpl diff --git a/docs/content/doc/developers/oauth2-provider.en-us.md b/docs/content/doc/developers/oauth2-provider.en-us.md index 17c12d22f242..1ef30a7f0e4a 100644 --- a/docs/content/doc/developers/oauth2-provider.en-us.md +++ b/docs/content/doc/developers/oauth2-provider.en-us.md @@ -60,6 +60,7 @@ Gitea supports the following scopes for tokens: |     **write:public_key** | Grant read/write access to public keys | |     **read:public_key** | Grant read-only access to public keys | | **admin:org_hook** | Grants full access to organizational-level hooks | +| **admin:user_hook** | Grants full access to user-level hooks | | **notification** | Grants full access to notifications | | **user** | Grants full access to user profile info | |     **read:user** | Grants read access to user's profile | diff --git a/models/auth/token_scope.go b/models/auth/token_scope.go index 38733a1c8f30..06c89fecc2e4 100644 --- a/models/auth/token_scope.go +++ b/models/auth/token_scope.go @@ -32,6 +32,8 @@ const ( AccessTokenScopeAdminOrgHook AccessTokenScope = "admin:org_hook" + AccessTokenScopeAdminUserHook AccessTokenScope = "admin:user_hook" + AccessTokenScopeNotification AccessTokenScope = "notification" AccessTokenScopeUser AccessTokenScope = "user" @@ -64,7 +66,7 @@ type AccessTokenScopeBitmap uint64 const ( // AccessTokenScopeAllBits is the bitmap of all access token scopes, except `sudo`. AccessTokenScopeAllBits AccessTokenScopeBitmap = AccessTokenScopeRepoBits | - AccessTokenScopeAdminOrgBits | AccessTokenScopeAdminPublicKeyBits | AccessTokenScopeAdminOrgHookBits | + AccessTokenScopeAdminOrgBits | AccessTokenScopeAdminPublicKeyBits | AccessTokenScopeAdminOrgHookBits | AccessTokenScopeAdminUserHookBits | AccessTokenScopeNotificationBits | AccessTokenScopeUserBits | AccessTokenScopeDeleteRepoBits | AccessTokenScopePackageBits | AccessTokenScopeAdminGPGKeyBits | AccessTokenScopeAdminApplicationBits @@ -86,6 +88,8 @@ const ( AccessTokenScopeAdminOrgHookBits AccessTokenScopeBitmap = 1 << iota + AccessTokenScopeAdminUserHookBits AccessTokenScopeBitmap = 1 << iota + AccessTokenScopeNotificationBits AccessTokenScopeBitmap = 1 << iota AccessTokenScopeUserBits AccessTokenScopeBitmap = 1< v245 NewMigration("Add NeedApproval to actions tables", v1_20.AddNeedApprovalToActionRun), + // v245 -> v246 + NewMigration("Rename Webhook org_id to owner_id", v1_20.RenameWebhookOrgToOwner), } // GetCurrentDBVersion returns the current db version diff --git a/models/migrations/v1_20/v245.go b/models/migrations/v1_20/v245.go new file mode 100644 index 000000000000..466f21c239d0 --- /dev/null +++ b/models/migrations/v1_20/v245.go @@ -0,0 +1,74 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_20 //nolint + +import ( + "context" + "fmt" + + "code.gitea.io/gitea/models/migrations/base" + "code.gitea.io/gitea/modules/setting" + + "xorm.io/xorm" +) + +func RenameWebhookOrgToOwner(x *xorm.Engine) error { + type Webhook struct { + OrgID int64 `xorm:"INDEX"` + } + + // This migration maybe rerun so that we should check if it has been run + ownerExist, err := x.Dialect().IsColumnExist(x.DB(), context.Background(), "webhook", "owner_id") + if err != nil { + return err + } + + if ownerExist { + orgExist, err := x.Dialect().IsColumnExist(x.DB(), context.Background(), "webhook", "org_id") + if err != nil { + return err + } + if !orgExist { + return nil + } + } + + sess := x.NewSession() + defer sess.Close() + if err := sess.Begin(); err != nil { + return err + } + + if err := sess.Sync2(new(Webhook)); err != nil { + return err + } + + if ownerExist { + if err := base.DropTableColumns(sess, "webhook", "owner_id"); err != nil { + return err + } + } + + switch { + case setting.Database.Type.IsMySQL(): + inferredTable, err := x.TableInfo(new(Webhook)) + if err != nil { + return err + } + sqlType := x.Dialect().SQLType(inferredTable.GetColumn("org_id")) + if _, err := sess.Exec(fmt.Sprintf("ALTER TABLE `webhook` CHANGE org_id owner_id %s", sqlType)); err != nil { + return err + } + case setting.Database.Type.IsMSSQL(): + if _, err := sess.Exec("sp_rename 'webhook.org_id', 'owner_id', 'COLUMN'"); err != nil { + return err + } + default: + if _, err := sess.Exec("ALTER TABLE `webhook` RENAME COLUMN org_id TO owner_id"); err != nil { + return err + } + } + + return sess.Commit() +} diff --git a/models/webhook/webhook.go b/models/webhook/webhook.go index 64119f149495..e3f6b593d977 100644 --- a/models/webhook/webhook.go +++ b/models/webhook/webhook.go @@ -122,7 +122,7 @@ func IsValidHookContentType(name string) bool { type Webhook struct { ID int64 `xorm:"pk autoincr"` RepoID int64 `xorm:"INDEX"` // An ID of 0 indicates either a default or system webhook - OrgID int64 `xorm:"INDEX"` + OwnerID int64 `xorm:"INDEX"` IsSystemWebhook bool URL string `xorm:"url TEXT"` HTTPMethod string `xorm:"http_method"` @@ -412,11 +412,11 @@ func GetWebhookByRepoID(repoID, id int64) (*Webhook, error) { }) } -// GetWebhookByOrgID returns webhook of organization by given ID. -func GetWebhookByOrgID(orgID, id int64) (*Webhook, error) { +// GetWebhookByOwnerID returns webhook of a user or organization by given ID. +func GetWebhookByOwnerID(ownerID, id int64) (*Webhook, error) { return getWebhook(&Webhook{ - ID: id, - OrgID: orgID, + ID: id, + OwnerID: ownerID, }) } @@ -424,7 +424,7 @@ func GetWebhookByOrgID(orgID, id int64) (*Webhook, error) { type ListWebhookOptions struct { db.ListOptions RepoID int64 - OrgID int64 + OwnerID int64 IsActive util.OptionalBool } @@ -433,8 +433,8 @@ func (opts *ListWebhookOptions) toCond() builder.Cond { if opts.RepoID != 0 { cond = cond.And(builder.Eq{"webhook.repo_id": opts.RepoID}) } - if opts.OrgID != 0 { - cond = cond.And(builder.Eq{"webhook.org_id": opts.OrgID}) + if opts.OwnerID != 0 { + cond = cond.And(builder.Eq{"webhook.owner_id": opts.OwnerID}) } if !opts.IsActive.IsNone() { cond = cond.And(builder.Eq{"webhook.is_active": opts.IsActive.IsTrue()}) @@ -503,10 +503,10 @@ func DeleteWebhookByRepoID(repoID, id int64) error { }) } -// DeleteWebhookByOrgID deletes webhook of organization by given ID. -func DeleteWebhookByOrgID(orgID, id int64) error { +// DeleteWebhookByOwnerID deletes webhook of a user or organization by given ID. +func DeleteWebhookByOwnerID(ownerID, id int64) error { return deleteWebhook(&Webhook{ - ID: id, - OrgID: orgID, + ID: id, + OwnerID: ownerID, }) } diff --git a/models/webhook/webhook_system.go b/models/webhook/webhook_system.go index 21dc0406a0d1..2e89f9547bba 100644 --- a/models/webhook/webhook_system.go +++ b/models/webhook/webhook_system.go @@ -15,7 +15,7 @@ import ( func GetDefaultWebhooks(ctx context.Context) ([]*Webhook, error) { webhooks := make([]*Webhook, 0, 5) return webhooks, db.GetEngine(ctx). - Where("repo_id=? AND org_id=? AND is_system_webhook=?", 0, 0, false). + Where("repo_id=? AND owner_id=? AND is_system_webhook=?", 0, 0, false). Find(&webhooks) } @@ -23,7 +23,7 @@ func GetDefaultWebhooks(ctx context.Context) ([]*Webhook, error) { func GetSystemOrDefaultWebhook(ctx context.Context, id int64) (*Webhook, error) { webhook := &Webhook{ID: id} has, err := db.GetEngine(ctx). - Where("repo_id=? AND org_id=?", 0, 0). + Where("repo_id=? AND owner_id=?", 0, 0). Get(webhook) if err != nil { return nil, err @@ -38,11 +38,11 @@ func GetSystemWebhooks(ctx context.Context, isActive util.OptionalBool) ([]*Webh webhooks := make([]*Webhook, 0, 5) if isActive.IsNone() { return webhooks, db.GetEngine(ctx). - Where("repo_id=? AND org_id=? AND is_system_webhook=?", 0, 0, true). + Where("repo_id=? AND owner_id=? AND is_system_webhook=?", 0, 0, true). Find(&webhooks) } return webhooks, db.GetEngine(ctx). - Where("repo_id=? AND org_id=? AND is_system_webhook=? AND is_active = ?", 0, 0, true, isActive.IsTrue()). + Where("repo_id=? AND owner_id=? AND is_system_webhook=? AND is_active = ?", 0, 0, true, isActive.IsTrue()). Find(&webhooks) } @@ -50,7 +50,7 @@ func GetSystemWebhooks(ctx context.Context, isActive util.OptionalBool) ([]*Webh func DeleteDefaultSystemWebhook(ctx context.Context, id int64) error { return db.WithTx(ctx, func(ctx context.Context) error { count, err := db.GetEngine(ctx). - Where("repo_id=? AND org_id=?", 0, 0). + Where("repo_id=? AND owner_id=?", 0, 0). Delete(&Webhook{ID: id}) if err != nil { return err diff --git a/models/webhook/webhook_test.go b/models/webhook/webhook_test.go index c368fc620e2f..74f7aeaa0302 100644 --- a/models/webhook/webhook_test.go +++ b/models/webhook/webhook_test.go @@ -109,13 +109,13 @@ func TestGetWebhookByRepoID(t *testing.T) { assert.True(t, IsErrWebhookNotExist(err)) } -func TestGetWebhookByOrgID(t *testing.T) { +func TestGetWebhookByOwnerID(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - hook, err := GetWebhookByOrgID(3, 3) + hook, err := GetWebhookByOwnerID(3, 3) assert.NoError(t, err) assert.Equal(t, int64(3), hook.ID) - _, err = GetWebhookByOrgID(unittest.NonexistentID, unittest.NonexistentID) + _, err = GetWebhookByOwnerID(unittest.NonexistentID, unittest.NonexistentID) assert.Error(t, err) assert.True(t, IsErrWebhookNotExist(err)) } @@ -140,9 +140,9 @@ func TestGetWebhooksByRepoID(t *testing.T) { } } -func TestGetActiveWebhooksByOrgID(t *testing.T) { +func TestGetActiveWebhooksByOwnerID(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - hooks, err := ListWebhooksByOpts(db.DefaultContext, &ListWebhookOptions{OrgID: 3, IsActive: util.OptionalBoolTrue}) + hooks, err := ListWebhooksByOpts(db.DefaultContext, &ListWebhookOptions{OwnerID: 3, IsActive: util.OptionalBoolTrue}) assert.NoError(t, err) if assert.Len(t, hooks, 1) { assert.Equal(t, int64(3), hooks[0].ID) @@ -150,9 +150,9 @@ func TestGetActiveWebhooksByOrgID(t *testing.T) { } } -func TestGetWebhooksByOrgID(t *testing.T) { +func TestGetWebhooksByOwnerID(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - hooks, err := ListWebhooksByOpts(db.DefaultContext, &ListWebhookOptions{OrgID: 3}) + hooks, err := ListWebhooksByOpts(db.DefaultContext, &ListWebhookOptions{OwnerID: 3}) assert.NoError(t, err) if assert.Len(t, hooks, 1) { assert.Equal(t, int64(3), hooks[0].ID) @@ -181,13 +181,13 @@ func TestDeleteWebhookByRepoID(t *testing.T) { assert.True(t, IsErrWebhookNotExist(err)) } -func TestDeleteWebhookByOrgID(t *testing.T) { +func TestDeleteWebhookByOwnerID(t *testing.T) { assert.NoError(t, unittest.PrepareTestDatabase()) - unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 3, OrgID: 3}) - assert.NoError(t, DeleteWebhookByOrgID(3, 3)) - unittest.AssertNotExistsBean(t, &Webhook{ID: 3, OrgID: 3}) + unittest.AssertExistsAndLoadBean(t, &Webhook{ID: 3, OwnerID: 3}) + assert.NoError(t, DeleteWebhookByOwnerID(3, 3)) + unittest.AssertNotExistsBean(t, &Webhook{ID: 3, OwnerID: 3}) - err := DeleteWebhookByOrgID(unittest.NonexistentID, unittest.NonexistentID) + err := DeleteWebhookByOwnerID(unittest.NonexistentID, unittest.NonexistentID) assert.Error(t, err) assert.True(t, IsErrWebhookNotExist(err)) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 6f0d06a6e8c2..095257b365fb 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -821,6 +821,8 @@ remove_account_link = Remove Linked Account remove_account_link_desc = Removing a linked account will revoke its access to your Gitea account. Continue? remove_account_link_success = The linked account has been removed. +hooks.desc = Add webhooks which will be triggered for all repositories owned by this user. + orgs_none = You are not a member of any organizations. repos_none = You do not own any repositories diff --git a/routers/api/v1/admin/hooks.go b/routers/api/v1/admin/hooks.go index 2aed4139f3cb..8264503c9d2f 100644 --- a/routers/api/v1/admin/hooks.go +++ b/routers/api/v1/admin/hooks.go @@ -105,10 +105,7 @@ func CreateHook(ctx *context.APIContext) { // "$ref": "#/responses/Hook" form := web.GetForm(ctx).(*api.CreateHookOption) - // TODO in body params - if !utils.CheckCreateHookOption(ctx, form) { - return - } + utils.AddSystemHook(ctx, form) } diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 1d2f8b18e019..735939a5517c 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -835,6 +835,13 @@ func Routes(ctx gocontext.Context) *web.Route { m.Get("/stopwatches", reqToken(auth_model.AccessTokenScopeRepo), repo.GetStopwatches) m.Get("/subscriptions", reqToken(auth_model.AccessTokenScopeRepo), user.GetMyWatchedRepos) m.Get("/teams", reqToken(auth_model.AccessTokenScopeRepo), org.ListUserTeams) + m.Group("/hooks", func() { + m.Combo("").Get(user.ListHooks). + Post(bind(api.CreateHookOption{}), user.CreateHook) + m.Combo("/{id}").Get(user.GetHook). + Patch(bind(api.EditHookOption{}), user.EditHook). + Delete(user.DeleteHook) + }, reqToken(auth_model.AccessTokenScopeAdminUserHook), reqWebhooksEnabled()) }, reqToken("")) // Repositories diff --git a/routers/api/v1/org/hook.go b/routers/api/v1/org/hook.go index 4e435c9599a1..a6ea618a7d89 100644 --- a/routers/api/v1/org/hook.go +++ b/routers/api/v1/org/hook.go @@ -6,7 +6,6 @@ package org import ( "net/http" - webhook_model "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/context" api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web" @@ -39,34 +38,10 @@ func ListHooks(ctx *context.APIContext) { // "200": // "$ref": "#/responses/HookList" - opts := &webhook_model.ListWebhookOptions{ - ListOptions: utils.GetListOptions(ctx), - OrgID: ctx.Org.Organization.ID, - } - - count, err := webhook_model.CountWebhooksByOpts(opts) - if err != nil { - ctx.InternalServerError(err) - return - } - - orgHooks, err := webhook_model.ListWebhooksByOpts(ctx, opts) - if err != nil { - ctx.InternalServerError(err) - return - } - - hooks := make([]*api.Hook, len(orgHooks)) - for i, hook := range orgHooks { - hooks[i], err = webhook_service.ToHook(ctx.Org.Organization.AsUser().HomeLink(), hook) - if err != nil { - ctx.InternalServerError(err) - return - } - } - - ctx.SetTotalCountHeader(count) - ctx.JSON(http.StatusOK, hooks) + utils.ListOwnerHooks( + ctx, + ctx.ContextUser, + ) } // GetHook get an organization's hook by id @@ -92,14 +67,12 @@ func GetHook(ctx *context.APIContext) { // "200": // "$ref": "#/responses/Hook" - org := ctx.Org.Organization - hookID := ctx.ParamsInt64(":id") - hook, err := utils.GetOrgHook(ctx, org.ID, hookID) + hook, err := utils.GetOwnerHook(ctx, ctx.ContextUser.ID, ctx.ParamsInt64("id")) if err != nil { return } - apiHook, err := webhook_service.ToHook(org.AsUser().HomeLink(), hook) + apiHook, err := webhook_service.ToHook(ctx.ContextUser.HomeLink(), hook) if err != nil { ctx.InternalServerError(err) return @@ -131,15 +104,14 @@ func CreateHook(ctx *context.APIContext) { // "201": // "$ref": "#/responses/Hook" - form := web.GetForm(ctx).(*api.CreateHookOption) - // TODO in body params - if !utils.CheckCreateHookOption(ctx, form) { - return - } - utils.AddOrgHook(ctx, form) + utils.AddOwnerHook( + ctx, + ctx.ContextUser, + web.GetForm(ctx).(*api.CreateHookOption), + ) } -// EditHook modify a hook of a repository +// EditHook modify a hook of an organization func EditHook(ctx *context.APIContext) { // swagger:operation PATCH /orgs/{org}/hooks/{id} organization orgEditHook // --- @@ -168,11 +140,12 @@ func EditHook(ctx *context.APIContext) { // "200": // "$ref": "#/responses/Hook" - form := web.GetForm(ctx).(*api.EditHookOption) - - // TODO in body params - hookID := ctx.ParamsInt64(":id") - utils.EditOrgHook(ctx, form, hookID) + utils.EditOwnerHook( + ctx, + ctx.ContextUser, + web.GetForm(ctx).(*api.EditHookOption), + ctx.ParamsInt64("id"), + ) } // DeleteHook delete a hook of an organization @@ -198,15 +171,9 @@ func DeleteHook(ctx *context.APIContext) { // "204": // "$ref": "#/responses/empty" - org := ctx.Org.Organization - hookID := ctx.ParamsInt64(":id") - if err := webhook_model.DeleteWebhookByOrgID(org.ID, hookID); err != nil { - if webhook_model.IsErrWebhookNotExist(err) { - ctx.NotFound() - } else { - ctx.Error(http.StatusInternalServerError, "DeleteWebhookByOrgID", err) - } - return - } - ctx.Status(http.StatusNoContent) + utils.DeleteOwnerHook( + ctx, + ctx.ContextUser, + ctx.ParamsInt64("id"), + ) } diff --git a/routers/api/v1/repo/hook.go b/routers/api/v1/repo/hook.go index fd54d1f740ef..39d83912b016 100644 --- a/routers/api/v1/repo/hook.go +++ b/routers/api/v1/repo/hook.go @@ -223,12 +223,8 @@ func CreateHook(ctx *context.APIContext) { // responses: // "201": // "$ref": "#/responses/Hook" - form := web.GetForm(ctx).(*api.CreateHookOption) - if !utils.CheckCreateHookOption(ctx, form) { - return - } - utils.AddRepoHook(ctx, form) + utils.AddRepoHook(ctx, web.GetForm(ctx).(*api.CreateHookOption)) } // EditHook modify a hook of a repository diff --git a/routers/api/v1/user/hook.go b/routers/api/v1/user/hook.go new file mode 100644 index 000000000000..50be519c815f --- /dev/null +++ b/routers/api/v1/user/hook.go @@ -0,0 +1,154 @@ +// Copyright 2022 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "net/http" + + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + "code.gitea.io/gitea/routers/api/v1/utils" + webhook_service "code.gitea.io/gitea/services/webhook" +) + +// ListHooks list the authenticated user's webhooks +func ListHooks(ctx *context.APIContext) { + // swagger:operation GET /user/hooks user userListHooks + // --- + // summary: List the authenticated user's webhooks + // produces: + // - application/json + // parameters: + // - name: page + // in: query + // description: page number of results to return (1-based) + // type: integer + // - name: limit + // in: query + // description: page size of results + // type: integer + // responses: + // "200": + // "$ref": "#/responses/HookList" + + utils.ListOwnerHooks( + ctx, + ctx.Doer, + ) +} + +// GetHook get the authenticated user's hook by id +func GetHook(ctx *context.APIContext) { + // swagger:operation GET /user/hooks/{id} user userGetHook + // --- + // summary: Get a hook + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the hook to get + // type: integer + // format: int64 + // required: true + // responses: + // "200": + // "$ref": "#/responses/Hook" + + hook, err := utils.GetOwnerHook(ctx, ctx.Doer.ID, ctx.ParamsInt64("id")) + if err != nil { + return + } + + apiHook, err := webhook_service.ToHook(ctx.Doer.HomeLink(), hook) + if err != nil { + ctx.InternalServerError(err) + return + } + ctx.JSON(http.StatusOK, apiHook) +} + +// CreateHook create a hook for the authenticated user +func CreateHook(ctx *context.APIContext) { + // swagger:operation POST /user/hooks user userCreateHook + // --- + // summary: Create a hook + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // required: true + // schema: + // "$ref": "#/definitions/CreateHookOption" + // responses: + // "201": + // "$ref": "#/responses/Hook" + + utils.AddOwnerHook( + ctx, + ctx.Doer, + web.GetForm(ctx).(*api.CreateHookOption), + ) +} + +// EditHook modify a hook of the authenticated user +func EditHook(ctx *context.APIContext) { + // swagger:operation PATCH /user/hooks/{id} user userEditHook + // --- + // summary: Update a hook + // consumes: + // - application/json + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the hook to update + // type: integer + // format: int64 + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/EditHookOption" + // responses: + // "200": + // "$ref": "#/responses/Hook" + + utils.EditOwnerHook( + ctx, + ctx.Doer, + web.GetForm(ctx).(*api.EditHookOption), + ctx.ParamsInt64("id"), + ) +} + +// DeleteHook delete a hook of the authenticated user +func DeleteHook(ctx *context.APIContext) { + // swagger:operation DELETE /user/hooks/{id} user userDeleteHook + // --- + // summary: Delete a hook + // produces: + // - application/json + // parameters: + // - name: id + // in: path + // description: id of the hook to delete + // type: integer + // format: int64 + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + + utils.DeleteOwnerHook( + ctx, + ctx.Doer, + ctx.ParamsInt64("id"), + ) +} diff --git a/routers/api/v1/utils/hook.go b/routers/api/v1/utils/hook.go index f6aaf74aff12..44625cc9b81b 100644 --- a/routers/api/v1/utils/hook.go +++ b/routers/api/v1/utils/hook.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/models/webhook" "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/json" @@ -18,15 +19,46 @@ import ( webhook_service "code.gitea.io/gitea/services/webhook" ) -// GetOrgHook get an organization's webhook. If there is an error, write to -// `ctx` accordingly and return the error -func GetOrgHook(ctx *context.APIContext, orgID, hookID int64) (*webhook.Webhook, error) { - w, err := webhook.GetWebhookByOrgID(orgID, hookID) +// ListOwnerHooks lists the webhooks of the provided owner +func ListOwnerHooks(ctx *context.APIContext, owner *user_model.User) { + opts := &webhook.ListWebhookOptions{ + ListOptions: GetListOptions(ctx), + OwnerID: owner.ID, + } + + count, err := webhook.CountWebhooksByOpts(opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + hooks, err := webhook.ListWebhooksByOpts(ctx, opts) + if err != nil { + ctx.InternalServerError(err) + return + } + + apiHooks := make([]*api.Hook, len(hooks)) + for i, hook := range hooks { + apiHooks[i], err = webhook_service.ToHook(owner.HomeLink(), hook) + if err != nil { + ctx.InternalServerError(err) + return + } + } + + ctx.SetTotalCountHeader(count) + ctx.JSON(http.StatusOK, apiHooks) +} + +// GetOwnerHook gets an user or organization webhook. Errors are written to ctx. +func GetOwnerHook(ctx *context.APIContext, ownerID, hookID int64) (*webhook.Webhook, error) { + w, err := webhook.GetWebhookByOwnerID(ownerID, hookID) if err != nil { if webhook.IsErrWebhookNotExist(err) { ctx.NotFound() } else { - ctx.Error(http.StatusInternalServerError, "GetWebhookByOrgID", err) + ctx.Error(http.StatusInternalServerError, "GetWebhookByOwnerID", err) } return nil, err } @@ -48,9 +80,9 @@ func GetRepoHook(ctx *context.APIContext, repoID, hookID int64) (*webhook.Webhoo return w, nil } -// CheckCreateHookOption check if a CreateHookOption form is valid. If invalid, +// checkCreateHookOption check if a CreateHookOption form is valid. If invalid, // write the appropriate error to `ctx`. Return whether the form is valid -func CheckCreateHookOption(ctx *context.APIContext, form *api.CreateHookOption) bool { +func checkCreateHookOption(ctx *context.APIContext, form *api.CreateHookOption) bool { if !webhook_service.IsValidHookTaskType(form.Type) { ctx.Error(http.StatusUnprocessableEntity, "", fmt.Sprintf("Invalid hook type: %s", form.Type)) return false @@ -81,14 +113,13 @@ func AddSystemHook(ctx *context.APIContext, form *api.CreateHookOption) { } } -// AddOrgHook add a hook to an organization. Writes to `ctx` accordingly -func AddOrgHook(ctx *context.APIContext, form *api.CreateHookOption) { - org := ctx.Org.Organization - hook, ok := addHook(ctx, form, org.ID, 0) +// AddOwnerHook adds a hook to an user or organization +func AddOwnerHook(ctx *context.APIContext, owner *user_model.User, form *api.CreateHookOption) { + hook, ok := addHook(ctx, form, owner.ID, 0) if !ok { return } - apiHook, ok := toAPIHook(ctx, org.AsUser().HomeLink(), hook) + apiHook, ok := toAPIHook(ctx, owner.HomeLink(), hook) if !ok { return } @@ -128,14 +159,18 @@ func pullHook(events []string, event string) bool { return util.SliceContainsString(events, event, true) || util.SliceContainsString(events, string(webhook_module.HookEventPullRequest), true) } -// addHook add the hook specified by `form`, `orgID` and `repoID`. If there is +// addHook add the hook specified by `form`, `ownerID` and `repoID`. If there is // an error, write to `ctx` accordingly. Return (webhook, ok) -func addHook(ctx *context.APIContext, form *api.CreateHookOption, orgID, repoID int64) (*webhook.Webhook, bool) { +func addHook(ctx *context.APIContext, form *api.CreateHookOption, ownerID, repoID int64) (*webhook.Webhook, bool) { + if !checkCreateHookOption(ctx, form) { + return nil, false + } + if len(form.Events) == 0 { form.Events = []string{"push"} } w := &webhook.Webhook{ - OrgID: orgID, + OwnerID: ownerID, RepoID: repoID, URL: form.Config["url"], ContentType: webhook.ToHookContentType(form.Config["content_type"]), @@ -234,21 +269,20 @@ func EditSystemHook(ctx *context.APIContext, form *api.EditHookOption, hookID in ctx.JSON(http.StatusOK, h) } -// EditOrgHook edit webhook `w` according to `form`. Writes to `ctx` accordingly -func EditOrgHook(ctx *context.APIContext, form *api.EditHookOption, hookID int64) { - org := ctx.Org.Organization - hook, err := GetOrgHook(ctx, org.ID, hookID) +// EditOwnerHook updates a webhook of an user or organization +func EditOwnerHook(ctx *context.APIContext, owner *user_model.User, form *api.EditHookOption, hookID int64) { + hook, err := GetOwnerHook(ctx, owner.ID, hookID) if err != nil { return } if !editHook(ctx, form, hook) { return } - updated, err := GetOrgHook(ctx, org.ID, hookID) + updated, err := GetOwnerHook(ctx, owner.ID, hookID) if err != nil { return } - apiHook, ok := toAPIHook(ctx, org.AsUser().HomeLink(), updated) + apiHook, ok := toAPIHook(ctx, owner.HomeLink(), updated) if !ok { return } @@ -362,3 +396,16 @@ func editHook(ctx *context.APIContext, form *api.EditHookOption, w *webhook.Webh } return true } + +// DeleteOwnerHook deletes the hook owned by the owner. +func DeleteOwnerHook(ctx *context.APIContext, owner *user_model.User, hookID int64) { + if err := webhook.DeleteWebhookByOwnerID(owner.ID, hookID); err != nil { + if webhook.IsErrWebhookNotExist(err) { + ctx.NotFound() + } else { + ctx.Error(http.StatusInternalServerError, "DeleteWebhookByOwnerID", err) + } + return + } + ctx.Status(http.StatusNoContent) +} diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index f713d096639b..b57ebfbcda23 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -218,9 +218,9 @@ func Webhooks(ctx *context.Context) { ctx.Data["BaseLinkNew"] = ctx.Org.OrgLink + "/settings/hooks" ctx.Data["Description"] = ctx.Tr("org.settings.hooks_desc") - ws, err := webhook.ListWebhooksByOpts(ctx, &webhook.ListWebhookOptions{OrgID: ctx.Org.Organization.ID}) + ws, err := webhook.ListWebhooksByOpts(ctx, &webhook.ListWebhookOptions{OwnerID: ctx.Org.Organization.ID}) if err != nil { - ctx.ServerError("GetWebhooksByOrgId", err) + ctx.ServerError("ListWebhooksByOpts", err) return } @@ -230,8 +230,8 @@ func Webhooks(ctx *context.Context) { // DeleteWebhook response for delete webhook func DeleteWebhook(ctx *context.Context) { - if err := webhook.DeleteWebhookByOrgID(ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { - ctx.Flash.Error("DeleteWebhookByOrgID: " + err.Error()) + if err := webhook.DeleteWebhookByOwnerID(ctx.Org.Organization.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error()) } else { ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) } diff --git a/routers/web/repo/webhook.go b/routers/web/repo/webhook.go index d27d0f1bf01d..f30588967e83 100644 --- a/routers/web/repo/webhook.go +++ b/routers/web/repo/webhook.go @@ -33,6 +33,7 @@ const ( tplHooks base.TplName = "repo/settings/webhook/base" tplHookNew base.TplName = "repo/settings/webhook/new" tplOrgHookNew base.TplName = "org/settings/hook_new" + tplUserHookNew base.TplName = "user/settings/hook_new" tplAdminHookNew base.TplName = "admin/hook_new" ) @@ -54,8 +55,8 @@ func Webhooks(ctx *context.Context) { ctx.HTML(http.StatusOK, tplHooks) } -type orgRepoCtx struct { - OrgID int64 +type ownerRepoCtx struct { + OwnerID int64 RepoID int64 IsAdmin bool IsSystemWebhook bool @@ -64,10 +65,10 @@ type orgRepoCtx struct { NewTemplate base.TplName } -// getOrgRepoCtx determines whether this is a repo, organization, or admin (both default and system) context. -func getOrgRepoCtx(ctx *context.Context) (*orgRepoCtx, error) { - if len(ctx.Repo.RepoLink) > 0 { - return &orgRepoCtx{ +// getOwnerRepoCtx determines whether this is a repo, owner, or admin (both default and system) context. +func getOwnerRepoCtx(ctx *context.Context) (*ownerRepoCtx, error) { + if is, ok := ctx.Data["IsRepositoryWebhook"]; ok && is.(bool) { + return &ownerRepoCtx{ RepoID: ctx.Repo.Repository.ID, Link: path.Join(ctx.Repo.RepoLink, "settings/hooks"), LinkNew: path.Join(ctx.Repo.RepoLink, "settings/hooks"), @@ -75,37 +76,35 @@ func getOrgRepoCtx(ctx *context.Context) (*orgRepoCtx, error) { }, nil } - if len(ctx.Org.OrgLink) > 0 { - return &orgRepoCtx{ - OrgID: ctx.Org.Organization.ID, + if is, ok := ctx.Data["IsOrganizationWebhook"]; ok && is.(bool) { + return &ownerRepoCtx{ + OwnerID: ctx.ContextUser.ID, Link: path.Join(ctx.Org.OrgLink, "settings/hooks"), LinkNew: path.Join(ctx.Org.OrgLink, "settings/hooks"), NewTemplate: tplOrgHookNew, }, nil } - if ctx.Doer.IsAdmin { - // Are we looking at default webhooks? - if ctx.Params(":configType") == "default-hooks" { - return &orgRepoCtx{ - IsAdmin: true, - Link: path.Join(setting.AppSubURL, "/admin/hooks"), - LinkNew: path.Join(setting.AppSubURL, "/admin/default-hooks"), - NewTemplate: tplAdminHookNew, - }, nil - } + if is, ok := ctx.Data["IsUserWebhook"]; ok && is.(bool) { + return &ownerRepoCtx{ + OwnerID: ctx.Doer.ID, + Link: path.Join(setting.AppSubURL, "/user/settings/hooks"), + LinkNew: path.Join(setting.AppSubURL, "/user/settings/hooks"), + NewTemplate: tplUserHookNew, + }, nil + } - // Must be system webhooks instead - return &orgRepoCtx{ + if ctx.Doer.IsAdmin { + return &ownerRepoCtx{ IsAdmin: true, - IsSystemWebhook: true, + IsSystemWebhook: ctx.Params(":configType") == "system-hooks", Link: path.Join(setting.AppSubURL, "/admin/hooks"), LinkNew: path.Join(setting.AppSubURL, "/admin/system-hooks"), NewTemplate: tplAdminHookNew, }, nil } - return nil, errors.New("unable to set OrgRepo context") + return nil, errors.New("unable to set OwnerRepo context") } func checkHookType(ctx *context.Context) string { @@ -122,9 +121,9 @@ func WebhooksNew(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.settings.add_webhook") ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook_module.HookEvent{}} - orCtx, err := getOrgRepoCtx(ctx) + orCtx, err := getOwnerRepoCtx(ctx) if err != nil { - ctx.ServerError("getOrgRepoCtx", err) + ctx.ServerError("getOwnerRepoCtx", err) return } @@ -205,9 +204,9 @@ func createWebhook(ctx *context.Context, params webhookParams) { ctx.Data["Webhook"] = webhook.Webhook{HookEvent: &webhook_module.HookEvent{}} ctx.Data["HookType"] = params.Type - orCtx, err := getOrgRepoCtx(ctx) + orCtx, err := getOwnerRepoCtx(ctx) if err != nil { - ctx.ServerError("getOrgRepoCtx", err) + ctx.ServerError("getOwnerRepoCtx", err) return } ctx.Data["BaseLink"] = orCtx.LinkNew @@ -236,7 +235,7 @@ func createWebhook(ctx *context.Context, params webhookParams) { IsActive: params.WebhookForm.Active, Type: params.Type, Meta: string(meta), - OrgID: orCtx.OrgID, + OwnerID: orCtx.OwnerID, IsSystemWebhook: orCtx.IsSystemWebhook, } err = w.SetHeaderAuthorization(params.WebhookForm.AuthorizationHeader) @@ -577,19 +576,19 @@ func packagistHookParams(ctx *context.Context) webhookParams { } } -func checkWebhook(ctx *context.Context) (*orgRepoCtx, *webhook.Webhook) { - orCtx, err := getOrgRepoCtx(ctx) +func checkWebhook(ctx *context.Context) (*ownerRepoCtx, *webhook.Webhook) { + orCtx, err := getOwnerRepoCtx(ctx) if err != nil { - ctx.ServerError("getOrgRepoCtx", err) + ctx.ServerError("getOwnerRepoCtx", err) return nil, nil } ctx.Data["BaseLink"] = orCtx.Link var w *webhook.Webhook if orCtx.RepoID > 0 { - w, err = webhook.GetWebhookByRepoID(ctx.Repo.Repository.ID, ctx.ParamsInt64(":id")) - } else if orCtx.OrgID > 0 { - w, err = webhook.GetWebhookByOrgID(ctx.Org.Organization.ID, ctx.ParamsInt64(":id")) + w, err = webhook.GetWebhookByRepoID(orCtx.RepoID, ctx.ParamsInt64(":id")) + } else if orCtx.OwnerID > 0 { + w, err = webhook.GetWebhookByOwnerID(orCtx.OwnerID, ctx.ParamsInt64(":id")) } else if orCtx.IsAdmin { w, err = webhook.GetSystemOrDefaultWebhook(ctx, ctx.ParamsInt64(":id")) } diff --git a/routers/web/user/setting/webhooks.go b/routers/web/user/setting/webhooks.go new file mode 100644 index 000000000000..9b0b0c9611c1 --- /dev/null +++ b/routers/web/user/setting/webhooks.go @@ -0,0 +1,48 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package setting + +import ( + "net/http" + + "code.gitea.io/gitea/models/webhook" + "code.gitea.io/gitea/modules/base" + "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/setting" +) + +const ( + tplSettingsHooks base.TplName = "user/settings/hooks" +) + +// Webhooks render webhook list page +func Webhooks(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("settings") + ctx.Data["PageIsSettingsHooks"] = true + ctx.Data["BaseLink"] = setting.AppSubURL + "/user/settings/hooks" + ctx.Data["BaseLinkNew"] = setting.AppSubURL + "/user/settings/hooks" + ctx.Data["Description"] = ctx.Tr("settings.hooks.desc") + + ws, err := webhook.ListWebhooksByOpts(ctx, &webhook.ListWebhookOptions{OwnerID: ctx.Doer.ID}) + if err != nil { + ctx.ServerError("ListWebhooksByOpts", err) + return + } + + ctx.Data["Webhooks"] = ws + ctx.HTML(http.StatusOK, tplSettingsHooks) +} + +// DeleteWebhook response for delete webhook +func DeleteWebhook(ctx *context.Context) { + if err := webhook.DeleteWebhookByOwnerID(ctx.Doer.ID, ctx.FormInt64("id")); err != nil { + ctx.Flash.Error("DeleteWebhookByOwnerID: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("repo.settings.webhook_deletion_success")) + } + + ctx.JSON(http.StatusOK, map[string]interface{}{ + "redirect": setting.AppSubURL + "/user/settings/hooks", + }) +} diff --git a/routers/web/web.go b/routers/web/web.go index ff312992dda0..e4179d580295 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -315,6 +315,35 @@ func RegisterRoutes(m *web.Route) { } } + addWebhookAddRoutes := func() { + m.Get("/{type}/new", repo.WebhooksNew) + m.Post("/gitea/new", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksNewPost) + m.Post("/gogs/new", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksNewPost) + m.Post("/slack/new", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksNewPost) + m.Post("/discord/new", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksNewPost) + m.Post("/dingtalk/new", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksNewPost) + m.Post("/telegram/new", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksNewPost) + m.Post("/matrix/new", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksNewPost) + m.Post("/msteams/new", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksNewPost) + m.Post("/feishu/new", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost) + m.Post("/wechatwork/new", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksNewPost) + m.Post("/packagist/new", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksNewPost) + } + + addWebhookEditRoutes := func() { + m.Post("/gitea/{id}", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksEditPost) + m.Post("/gogs/{id}", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksEditPost) + m.Post("/slack/{id}", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksEditPost) + m.Post("/discord/{id}", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksEditPost) + m.Post("/dingtalk/{id}", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksEditPost) + m.Post("/telegram/{id}", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksEditPost) + m.Post("/matrix/{id}", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksEditPost) + m.Post("/msteams/{id}", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksEditPost) + m.Post("/feishu/{id}", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost) + m.Post("/wechatwork/{id}", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksEditPost) + m.Post("/packagist/{id}", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksEditPost) + } + // FIXME: not all routes need go through same middleware. // Especially some AJAX requests, we can reduce middleware number to improve performance. // Routers. @@ -482,6 +511,19 @@ func RegisterRoutes(m *web.Route) { m.Get("/organization", user_setting.Organization) m.Get("/repos", user_setting.Repos) m.Post("/repos/unadopted", user_setting.AdoptOrDeleteRepository) + + m.Group("/hooks", func() { + m.Get("", user_setting.Webhooks) + m.Post("/delete", user_setting.DeleteWebhook) + addWebhookAddRoutes() + m.Group("/{id}", func() { + m.Get("", repo.WebHooksEdit) + m.Post("/replay/{uuid}", repo.ReplayWebhook) + }) + addWebhookEditRoutes() + }, webhooksEnabled, func(ctx *context.Context) { + ctx.Data["IsUserWebhook"] = true + }) }, reqSignIn, func(ctx *context.Context) { ctx.Data["PageIsUserSettings"] = true ctx.Data["AllThemes"] = setting.UI.Themes @@ -575,32 +617,11 @@ func RegisterRoutes(m *web.Route) { m.Get("", repo.WebHooksEdit) m.Post("/replay/{uuid}", repo.ReplayWebhook) }) - m.Post("/gitea/{id}", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksEditPost) - m.Post("/gogs/{id}", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksEditPost) - m.Post("/slack/{id}", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksEditPost) - m.Post("/discord/{id}", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksEditPost) - m.Post("/dingtalk/{id}", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksEditPost) - m.Post("/telegram/{id}", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksEditPost) - m.Post("/matrix/{id}", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksEditPost) - m.Post("/msteams/{id}", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksEditPost) - m.Post("/feishu/{id}", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost) - m.Post("/wechatwork/{id}", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksEditPost) - m.Post("/packagist/{id}", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksEditPost) + addWebhookEditRoutes() }, webhooksEnabled) m.Group("/{configType:default-hooks|system-hooks}", func() { - m.Get("/{type}/new", repo.WebhooksNew) - m.Post("/gitea/new", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksNewPost) - m.Post("/gogs/new", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksNewPost) - m.Post("/slack/new", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksNewPost) - m.Post("/discord/new", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksNewPost) - m.Post("/dingtalk/new", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksNewPost) - m.Post("/telegram/new", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksNewPost) - m.Post("/matrix/new", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksNewPost) - m.Post("/msteams/new", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksNewPost) - m.Post("/feishu/new", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost) - m.Post("/wechatwork/new", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksNewPost) - m.Post("/packagist/new", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksNewPost) + addWebhookAddRoutes() }) m.Group("/auths", func() { @@ -759,32 +780,15 @@ func RegisterRoutes(m *web.Route) { m.Group("/hooks", func() { m.Get("", org.Webhooks) m.Post("/delete", org.DeleteWebhook) - m.Get("/{type}/new", repo.WebhooksNew) - m.Post("/gitea/new", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksNewPost) - m.Post("/gogs/new", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksNewPost) - m.Post("/slack/new", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksNewPost) - m.Post("/discord/new", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksNewPost) - m.Post("/dingtalk/new", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksNewPost) - m.Post("/telegram/new", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksNewPost) - m.Post("/matrix/new", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksNewPost) - m.Post("/msteams/new", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksNewPost) - m.Post("/feishu/new", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost) - m.Post("/wechatwork/new", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksNewPost) + addWebhookAddRoutes() m.Group("/{id}", func() { m.Get("", repo.WebHooksEdit) m.Post("/replay/{uuid}", repo.ReplayWebhook) }) - m.Post("/gitea/{id}", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksEditPost) - m.Post("/gogs/{id}", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksEditPost) - m.Post("/slack/{id}", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksEditPost) - m.Post("/discord/{id}", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksEditPost) - m.Post("/dingtalk/{id}", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksEditPost) - m.Post("/telegram/{id}", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksEditPost) - m.Post("/matrix/{id}", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksEditPost) - m.Post("/msteams/{id}", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksEditPost) - m.Post("/feishu/{id}", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost) - m.Post("/wechatwork/{id}", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksEditPost) - }, webhooksEnabled) + addWebhookEditRoutes() + }, webhooksEnabled, func(ctx *context.Context) { + ctx.Data["IsOrganizationWebhook"] = true + }) m.Group("/labels", func() { m.Get("", org.RetrieveLabels, org.Labels) @@ -962,35 +966,16 @@ func RegisterRoutes(m *web.Route) { m.Group("/hooks", func() { m.Get("", repo.Webhooks) m.Post("/delete", repo.DeleteWebhook) - m.Get("/{type}/new", repo.WebhooksNew) - m.Post("/gitea/new", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksNewPost) - m.Post("/gogs/new", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksNewPost) - m.Post("/slack/new", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksNewPost) - m.Post("/discord/new", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksNewPost) - m.Post("/dingtalk/new", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksNewPost) - m.Post("/telegram/new", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksNewPost) - m.Post("/matrix/new", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksNewPost) - m.Post("/msteams/new", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksNewPost) - m.Post("/feishu/new", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksNewPost) - m.Post("/wechatwork/new", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksNewPost) - m.Post("/packagist/new", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksNewPost) + addWebhookAddRoutes() m.Group("/{id}", func() { m.Get("", repo.WebHooksEdit) m.Post("/test", repo.TestWebhook) m.Post("/replay/{uuid}", repo.ReplayWebhook) }) - m.Post("/gitea/{id}", web.Bind(forms.NewWebhookForm{}), repo.GiteaHooksEditPost) - m.Post("/gogs/{id}", web.Bind(forms.NewGogshookForm{}), repo.GogsHooksEditPost) - m.Post("/slack/{id}", web.Bind(forms.NewSlackHookForm{}), repo.SlackHooksEditPost) - m.Post("/discord/{id}", web.Bind(forms.NewDiscordHookForm{}), repo.DiscordHooksEditPost) - m.Post("/dingtalk/{id}", web.Bind(forms.NewDingtalkHookForm{}), repo.DingtalkHooksEditPost) - m.Post("/telegram/{id}", web.Bind(forms.NewTelegramHookForm{}), repo.TelegramHooksEditPost) - m.Post("/matrix/{id}", web.Bind(forms.NewMatrixHookForm{}), repo.MatrixHooksEditPost) - m.Post("/msteams/{id}", web.Bind(forms.NewMSTeamsHookForm{}), repo.MSTeamsHooksEditPost) - m.Post("/feishu/{id}", web.Bind(forms.NewFeishuHookForm{}), repo.FeishuHooksEditPost) - m.Post("/wechatwork/{id}", web.Bind(forms.NewWechatWorkHookForm{}), repo.WechatworkHooksEditPost) - m.Post("/packagist/{id}", web.Bind(forms.NewPackagistHookForm{}), repo.PackagistHooksEditPost) - }, webhooksEnabled) + addWebhookEditRoutes() + }, webhooksEnabled, func(ctx *context.Context) { + ctx.Data["IsRepositoryWebhook"] = true + }) m.Group("/keys", func() { m.Combo("").Get(repo.DeployKeys). diff --git a/services/repository/hooks.go b/services/repository/hooks.go index a8b6f7a62228..8506fa341369 100644 --- a/services/repository/hooks.go +++ b/services/repository/hooks.go @@ -101,7 +101,7 @@ func GenerateWebhooks(ctx context.Context, templateRepo, generateRepo *repo_mode HookEvent: templateWebhook.HookEvent, IsActive: templateWebhook.IsActive, Type: templateWebhook.Type, - OrgID: templateWebhook.OrgID, + OwnerID: templateWebhook.OwnerID, Events: templateWebhook.Events, Meta: templateWebhook.Meta, }) diff --git a/services/webhook/webhook.go b/services/webhook/webhook.go index afd8e3c105cb..b862d5bff10e 100644 --- a/services/webhook/webhook.go +++ b/services/webhook/webhook.go @@ -229,16 +229,16 @@ func PrepareWebhooks(ctx context.Context, source EventSource, event webhook_modu owner = source.Repository.MustOwner(ctx) } - // check if owner is an org and append additional webhooks - if owner != nil && owner.IsOrganization() { - orgHooks, err := webhook_model.ListWebhooksByOpts(ctx, &webhook_model.ListWebhookOptions{ - OrgID: owner.ID, + // append additional webhooks of a user or organization + if owner != nil { + ownerHooks, err := webhook_model.ListWebhooksByOpts(ctx, &webhook_model.ListWebhookOptions{ + OwnerID: owner.ID, IsActive: util.OptionalBoolTrue, }) if err != nil { return fmt.Errorf("ListWebhooksByOpts: %w", err) } - ws = append(ws, orgHooks...) + ws = append(ws, ownerHooks...) } // Add any admin-defined system webhooks diff --git a/templates/repo/settings/webhook/history.tmpl b/templates/repo/settings/webhook/history.tmpl index bf7fe05de224..f76cdb147d7c 100644 --- a/templates/repo/settings/webhook/history.tmpl +++ b/templates/repo/settings/webhook/history.tmpl @@ -40,7 +40,7 @@ N/A {{end}} - {{if or $.Permission.IsAdmin $.IsOrganizationOwner $.PageIsAdmin}} + {{if or $.Permission.IsAdmin $.IsOrganizationOwner $.PageIsAdmin $.PageIsUserSettings}} +
+
+ + +
+
diff --git a/templates/user/settings/hook_new.tmpl b/templates/user/settings/hook_new.tmpl new file mode 100644 index 000000000000..20aaf65f62d7 --- /dev/null +++ b/templates/user/settings/hook_new.tmpl @@ -0,0 +1,53 @@ +{{template "base/head" .}} +
+ {{template "user/settings/navbar" .}} +
+
+ {{template "base/alert" .}} +

+ {{if .PageIsSettingsHooksNew}}{{.locale.Tr "repo.settings.add_webhook"}}{{else}}{{.locale.Tr "repo.settings.update_webhook"}}{{end}} +
+ {{if eq .HookType "gitea"}} + + {{else if eq .HookType "gogs"}} + + {{else if eq .HookType "slack"}} + + {{else if eq .HookType "discord"}} + + {{else if eq .HookType "dingtalk"}} + + {{else if eq .HookType "telegram"}} + + {{else if eq .HookType "msteams"}} + + {{else if eq .HookType "feishu"}} + + {{else if eq .HookType "matrix"}} + + {{else if eq .HookType "wechatwork"}} + + {{else if eq .HookType "packagist"}} + + {{end}} +
+

+
+ {{template "repo/settings/webhook/gitea" .}} + {{template "repo/settings/webhook/gogs" .}} + {{template "repo/settings/webhook/slack" .}} + {{template "repo/settings/webhook/discord" .}} + {{template "repo/settings/webhook/dingtalk" .}} + {{template "repo/settings/webhook/telegram" .}} + {{template "repo/settings/webhook/msteams" .}} + {{template "repo/settings/webhook/feishu" .}} + {{template "repo/settings/webhook/matrix" .}} + {{template "repo/settings/webhook/wechatwork" .}} + {{template "repo/settings/webhook/packagist" .}} +
+ + {{template "repo/settings/webhook/history" .}} +
+
+
+{{template "base/footer" .}} diff --git a/templates/user/settings/hooks.tmpl b/templates/user/settings/hooks.tmpl new file mode 100644 index 000000000000..02bfa8a4e670 --- /dev/null +++ b/templates/user/settings/hooks.tmpl @@ -0,0 +1,8 @@ +{{template "base/head" .}} +
+ {{template "user/settings/navbar" .}} +
+ {{template "repo/settings/webhook/list" .}} +
+
+{{template "base/footer" .}} diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index 8deffde0b224..4afe2173c2bd 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -26,6 +26,11 @@ {{.locale.Tr "packages.title"}} {{end}} + {{if not DisableWebhooks}} + + {{.locale.Tr "repo.settings.hooks"}} + + {{end}} {{.locale.Tr "settings.organization"}} From cf29ee6dd290525635a0e1b823506e81f845b978 Mon Sep 17 00:00:00 2001 From: yp05327 <576951401@qq.com> Date: Sat, 11 Mar 2023 00:18:20 +0900 Subject: [PATCH 02/13] Add missing tabs to org projects page (#22705) Fixes https://github.com/go-gitea/gitea/issues/22676 Context Data `IsOrganizationMember` and `IsOrganizationOwner` is used to control the visibility of `people` and `team` tab. https://github.com/go-gitea/gitea/blob/2871ea08096cba15546f357d0ec473734ee9d8be/templates/org/menu.tmpl#L19-L40 And because of the reuse of user projects page, User Context is changed to Organization Context. But the value of `IsOrganizationMember` and `IsOrganizationOwner` are not being given. I reused func `HandleOrgAssignment` to add them to the ctx, but may have some unnecessary variables, idk whether it is ok. I found there is a missing `PageIsViewProjects` at create project page. --- models/organization/org.go | 26 ++++++++++ models/user/user.go | 5 ++ modules/context/org.go | 78 ++++++++++++++++------------- routers/web/org/home.go | 1 + routers/web/org/projects.go | 1 + routers/web/shared/user/header.go | 2 + routers/web/user/code.go | 1 + routers/web/user/profile.go | 1 + routers/web/web.go | 40 +++++++++------ services/context/user.go | 9 ---- templates/org/menu.tmpl | 8 +-- templates/user/overview/header.tmpl | 6 ++- 12 files changed, 115 insertions(+), 63 deletions(-) diff --git a/models/organization/org.go b/models/organization/org.go index f05027be729d..269b3e83288d 100644 --- a/models/organization/org.go +++ b/models/organization/org.go @@ -239,6 +239,32 @@ func (org *Organization) CustomAvatarRelativePath() string { return org.Avatar } +// UnitPermission returns unit permission +func (org *Organization) UnitPermission(ctx context.Context, doer *user_model.User, unitType unit.Type) perm.AccessMode { + if doer != nil { + teams, err := GetUserOrgTeams(ctx, org.ID, doer.ID) + if err != nil { + log.Error("GetUserOrgTeams: %v", err) + return perm.AccessModeNone + } + + if err := teams.LoadUnits(ctx); err != nil { + log.Error("LoadUnits: %v", err) + return perm.AccessModeNone + } + + if len(teams) > 0 { + return teams.UnitMaxAccess(unitType) + } + } + + if org.Visibility.IsPublic() { + return perm.AccessModeRead + } + + return perm.AccessModeNone +} + // CreateOrganization creates record of a new organization. func CreateOrganization(org *Organization, owner *user_model.User) (err error) { if !owner.CanCreateOrganization() { diff --git a/models/user/user.go b/models/user/user.go index f6fafe64f391..454779b9ea36 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -393,6 +393,11 @@ func (u *User) IsOrganization() bool { return u.Type == UserTypeOrganization } +// IsIndividual returns true if user is actually a individual user. +func (u *User) IsIndividual() bool { + return u.Type == UserTypeIndividual +} + // DisplayName returns full name if it's not empty, // returns username otherwise. func (u *User) DisplayName() string { diff --git a/modules/context/org.go b/modules/context/org.go index 0add7f2c0c3d..39a3038f910c 100644 --- a/modules/context/org.go +++ b/modules/context/org.go @@ -11,7 +11,6 @@ import ( "code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/unit" user_model "code.gitea.io/gitea/models/user" - "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/structs" ) @@ -31,29 +30,34 @@ type Organization struct { } func (org *Organization) CanWriteUnit(ctx *Context, unitType unit.Type) bool { - if ctx.Doer == nil { - return false - } - return org.UnitPermission(ctx, ctx.Doer.ID, unitType) >= perm.AccessModeWrite + return org.Organization.UnitPermission(ctx, ctx.Doer, unitType) >= perm.AccessModeWrite } -func (org *Organization) UnitPermission(ctx *Context, doerID int64, unitType unit.Type) perm.AccessMode { - if doerID > 0 { - teams, err := organization.GetUserOrgTeams(ctx, org.Organization.ID, doerID) - if err != nil { - log.Error("GetUserOrgTeams: %v", err) - return perm.AccessModeNone - } - if len(teams) > 0 { - return teams.UnitMaxAccess(unitType) - } - } +func (org *Organization) CanReadUnit(ctx *Context, unitType unit.Type) bool { + return org.Organization.UnitPermission(ctx, ctx.Doer, unitType) >= perm.AccessModeRead +} - if org.Organization.Visibility == structs.VisibleTypePublic { - return perm.AccessModeRead - } +func GetOrganizationByParams(ctx *Context) { + orgName := ctx.Params(":org") + + var err error - return perm.AccessModeNone + ctx.Org.Organization, err = organization.GetOrgByName(ctx, orgName) + if err != nil { + if organization.IsErrOrgNotExist(err) { + redirectUserID, err := user_model.LookupUserRedirect(orgName) + if err == nil { + RedirectToUser(ctx, orgName, redirectUserID) + } else if user_model.IsErrUserRedirectNotExist(err) { + ctx.NotFound("GetUserByName", err) + } else { + ctx.ServerError("LookupUserRedirect", err) + } + } else { + ctx.ServerError("GetUserByName", err) + } + return + } } // HandleOrgAssignment handles organization assignment @@ -77,25 +81,26 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { requireTeamAdmin = args[3] } - orgName := ctx.Params(":org") - var err error - ctx.Org.Organization, err = organization.GetOrgByName(ctx, orgName) - if err != nil { - if organization.IsErrOrgNotExist(err) { - redirectUserID, err := user_model.LookupUserRedirect(orgName) - if err == nil { - RedirectToUser(ctx, orgName, redirectUserID) - } else if user_model.IsErrUserRedirectNotExist(err) { - ctx.NotFound("GetUserByName", err) - } else { - ctx.ServerError("LookupUserRedirect", err) + + if ctx.ContextUser == nil { + // if Organization is not defined, get it from params + if ctx.Org.Organization == nil { + GetOrganizationByParams(ctx) + if ctx.Written() { + return } - } else { - ctx.ServerError("GetUserByName", err) } + } else if ctx.ContextUser.IsOrganization() { + if ctx.Org == nil { + ctx.Org = &Organization{} + } + ctx.Org.Organization = (*organization.Organization)(ctx.ContextUser) + } else { + // ContextUser is an individual User return } + org := ctx.Org.Organization // Handle Visibility @@ -156,6 +161,7 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { } ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner ctx.Data["IsOrganizationMember"] = ctx.Org.IsMember + ctx.Data["IsProjectEnabled"] = true ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled ctx.Data["IsPublicMember"] = func(uid int64) bool { @@ -231,6 +237,10 @@ func HandleOrgAssignment(ctx *Context, args ...bool) { return } } + + ctx.Data["CanReadProjects"] = ctx.Org.CanReadUnit(ctx, unit.TypeProjects) + ctx.Data["CanReadPackages"] = ctx.Org.CanReadUnit(ctx, unit.TypePackages) + ctx.Data["CanReadCode"] = ctx.Org.CanReadUnit(ctx, unit.TypeCode) } // OrgAssignment returns a middleware to handle organization assignment diff --git a/routers/web/org/home.go b/routers/web/org/home.go index 4cc364acd3a0..8c9cc8a9d86b 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -156,6 +156,7 @@ func Home(ctx *context.Context) { pager.SetDefaultParams(ctx) pager.AddParam(ctx, "language", "Language") ctx.Data["Page"] = pager + ctx.Data["ContextUser"] = ctx.ContextUser ctx.HTML(http.StatusOK, tplOrgHome) } diff --git a/routers/web/org/projects.go b/routers/web/org/projects.go index 64ae4aa70952..c9d63fec5df0 100644 --- a/routers/web/org/projects.go +++ b/routers/web/org/projects.go @@ -123,6 +123,7 @@ func NewProject(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.projects.new") ctx.Data["BoardTypes"] = project_model.GetBoardConfig() ctx.Data["CanWriteProjects"] = canWriteProjects(ctx) + ctx.Data["PageIsViewProjects"] = true ctx.Data["HomeLink"] = ctx.ContextUser.HomeLink() shared_user.RenderUserHeader(ctx) ctx.HTML(http.StatusOK, tplProjectsNew) diff --git a/routers/web/shared/user/header.go b/routers/web/shared/user/header.go index 94e59e2a490f..05e45f999eed 100644 --- a/routers/web/shared/user/header.go +++ b/routers/web/shared/user/header.go @@ -9,6 +9,8 @@ import ( ) func RenderUserHeader(ctx *context.Context) { + ctx.Data["IsProjectEnabled"] = true + ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled ctx.Data["ContextUser"] = ctx.ContextUser } diff --git a/routers/web/user/code.go b/routers/web/user/code.go index 81e3e65b4b67..b3adbcb8d3a8 100644 --- a/routers/web/user/code.go +++ b/routers/web/user/code.go @@ -24,6 +24,7 @@ func CodeSearch(ctx *context.Context) { return } + ctx.Data["IsProjectEnabled"] = true ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled ctx.Data["Title"] = ctx.Tr("explore.code") diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index b4452604599f..f4d458c040d7 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -304,6 +304,7 @@ func Profile(ctx *context.Context) { pager.AddParam(ctx, "date", "Date") } ctx.Data["Page"] = pager + ctx.Data["IsProjectEnabled"] = true ctx.Data["IsPackageEnabled"] = setting.Packages.Enabled ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled diff --git a/routers/web/web.go b/routers/web/web.go index e4179d580295..292268dc8055 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -711,6 +711,21 @@ func RegisterRoutes(m *web.Route) { } } + reqUnitAccess := func(unitType unit.Type, accessMode perm.AccessMode) func(ctx *context.Context) { + return func(ctx *context.Context) { + if ctx.ContextUser == nil { + ctx.NotFound(unitType.String(), nil) + return + } + if ctx.ContextUser.IsOrganization() { + if ctx.Org.Organization.UnitPermission(ctx, ctx.Doer, unitType) < accessMode { + ctx.NotFound(unitType.String(), nil) + return + } + } + } + } + // ***** START: Organization ***** m.Group("/org", func() { m.Group("/{org}", func() { @@ -873,8 +888,10 @@ func RegisterRoutes(m *web.Route) { } m.Group("/projects", func() { - m.Get("", org.Projects) - m.Get("/{id}", org.ViewProject) + m.Group("", func() { + m.Get("", org.Projects) + m.Get("/{id}", org.ViewProject) + }, reqUnitAccess(unit.TypeProjects, perm.AccessModeRead)) m.Group("", func() { //nolint:dupl m.Get("/new", org.NewProject) m.Post("/new", web.Bind(forms.CreateProjectForm{}), org.NewProjectPost) @@ -894,25 +911,18 @@ func RegisterRoutes(m *web.Route) { m.Post("/move", org.MoveIssues) }) }) - }, reqSignIn, func(ctx *context.Context) { - if ctx.ContextUser == nil { - ctx.NotFound("NewProject", nil) - return - } - if ctx.ContextUser.IsOrganization() { - if !ctx.Org.CanWriteUnit(ctx, unit.TypeProjects) { - ctx.NotFound("NewProject", nil) - return - } - } else if ctx.ContextUser.ID != ctx.Doer.ID { + }, reqSignIn, reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite), func(ctx *context.Context) { + if ctx.ContextUser.IsIndividual() && ctx.ContextUser.ID != ctx.Doer.ID { ctx.NotFound("NewProject", nil) return } }) }, repo.MustEnableProjects) - m.Get("/code", user.CodeSearch) - }, context_service.UserAssignmentWeb()) + m.Group("", func() { + m.Get("/code", user.CodeSearch) + }, reqUnitAccess(unit.TypeCode, perm.AccessModeRead)) + }, context_service.UserAssignmentWeb(), context.OrgAssignment()) // ***** Release Attachment Download without Signin m.Get("/{username}/{reponame}/releases/download/{vTag}/{fileName}", ignSignIn, context.RepoAssignment, repo.MustBeNotEmpty, repo.RedirectDownload) diff --git a/services/context/user.go b/services/context/user.go index 7642cba4e1f0..9dc84c3ac15e 100644 --- a/services/context/user.go +++ b/services/context/user.go @@ -8,7 +8,6 @@ import ( "net/http" "strings" - org_model "code.gitea.io/gitea/models/organization" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/context" ) @@ -57,14 +56,6 @@ func userAssignment(ctx *context.Context, errCb func(int, string, interface{})) } else { errCb(http.StatusInternalServerError, "GetUserByName", err) } - } else { - if ctx.ContextUser.IsOrganization() { - if ctx.Org == nil { - ctx.Org = &context.Organization{} - } - ctx.Org.Organization = (*org_model.Organization)(ctx.ContextUser) - ctx.Data["Org"] = ctx.Org.Organization - } } } } diff --git a/templates/org/menu.tmpl b/templates/org/menu.tmpl index 25f459c09c56..2a359d811134 100644 --- a/templates/org/menu.tmpl +++ b/templates/org/menu.tmpl @@ -3,16 +3,18 @@ {{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} + {{if and .IsProjectEnabled .CanReadProjects}} {{svg "octicon-project-symlink"}} {{.locale.Tr "user.projects"}} - {{if .IsPackageEnabled}} + {{end}} + {{if and .IsPackageEnabled .CanReadPackages}} {{svg "octicon-package"}} {{.locale.Tr "packages.title"}} {{end}} - {{if .IsRepoIndexerEnabled}} - + {{if and .IsRepoIndexerEnabled .CanReadCode}} + {{svg "octicon-code"}} {{$.locale.Tr "org.code"}} {{end}} diff --git a/templates/user/overview/header.tmpl b/templates/user/overview/header.tmpl index ce9ecb46adbc..b4f7d6f90091 100644 --- a/templates/user/overview/header.tmpl +++ b/templates/user/overview/header.tmpl @@ -22,15 +22,17 @@ {{svg "octicon-repo"}} {{.locale.Tr "user.repositories"}} + {{if and .IsProjectEnabled (or .ContextUser.IsIndividual (and .ContextUser.IsOrganization .CanReadProjects))}} {{svg "octicon-project-symlink"}} {{.locale.Tr "user.projects"}} - {{if (not .UnitPackagesGlobalDisabled)}} + {{end}} + {{if and .IsPackageEnabled (or .ContextUser.IsIndividual (and .ContextUser.IsOrganization .CanReadPackages))}} {{svg "octicon-package"}} {{.locale.Tr "packages.title"}} {{end}} - {{if .IsRepoIndexerEnabled}} + {{if and .IsRepoIndexerEnabled (or .ContextUser.IsIndividual (and .ContextUser.IsOrganization .CanReadCode))}} {{svg "octicon-code"}} {{.locale.Tr "user.code"}} From 5155ec35c571de8df62318df78c78cebc20e1aa0 Mon Sep 17 00:00:00 2001 From: sillyguodong <33891828+sillyguodong@users.noreply.github.com> Date: Fri, 10 Mar 2023 23:54:32 +0800 Subject: [PATCH 03/13] Parse external request id from request headers, and print it in access log (#22906) Close: #22890. --- ### Configure in .ini file: ```ini [log] REQUEST_ID_HEADERS = X-Request-ID, X-Trace-Id ``` ### Params in Request Header ``` X-Trace-ID: trace-id-1q2w3e4r ``` ![image](https://user-images.githubusercontent.com/33891828/218665296-8fd19a0f-ada6-4236-8bdb-f99201c703e8.png) ### Log output: ![image](https://user-images.githubusercontent.com/33891828/218665225-cc242a57-4ffc-449a-a1f6-f45ded0ead60.png) --- custom/conf/app.example.ini | 16 +++++++++ .../doc/advanced/config-cheat-sheet.en-us.md | 6 ++++ .../doc/advanced/config-cheat-sheet.zh-cn.md | 17 +++++++++- modules/context/access_log.go | 34 +++++++++++++++++++ modules/setting/log.go | 2 ++ 5 files changed, 74 insertions(+), 1 deletion(-) diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index b7875c12dd8e..c3c20a216c2d 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -576,6 +576,22 @@ ROUTER = console ;; The routing level will default to that of the system but individual router level can be set in ;; [log..router] LEVEL ;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; +;; Print request id which parsed from request headers in access log, when access log is enabled. +;; * E.g: +;; * In request Header: X-Request-ID: test-id-123 +;; * Configuration in app.ini: REQUEST_ID_HEADERS = X-Request-ID +;; * Print in log: 127.0.0.1:58384 - - [14/Feb/2023:16:33:51 +0800] "test-id-123" +;; +;; If you configure more than one in the .ini file, it will match in the order of configuration, +;; and the first match will be finally printed in the log. +;; * E.g: +;; * In reuqest Header: X-Trace-ID: trace-id-1q2w3e4r +;; * Configuration in app.ini: REQUEST_ID_HEADERS = X-Request-ID, X-Trace-ID, X-Req-ID +;; * Print in log: 127.0.0.1:58384 - - [14/Feb/2023:16:33:51 +0800] "trace-id-1q2w3e4r" +;; +;; REQUEST_ID_HEADERS = ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index c4ff8bafb920..a5ef977f15be 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -881,7 +881,13 @@ Default templates for project boards: - `Identity`: the SignedUserName or `"-"` if not logged in. - `Start`: the start time of the request. - `ResponseWriter`: the responseWriter from the request. + - `RequestID`: the value matching REQUEST_ID_HEADERS(default: `-`, if not matched). - You must be very careful to ensure that this template does not throw errors or panics as this template runs outside of the panic/recovery script. +- `REQUEST_ID_HEADERS`: **\**: You can configure multiple values that are splited by comma here. It will match in the order of configuration, and the first match will be finally printed in the access log. + - e.g. + - In the Request Header: X-Request-ID: **test-id-123** + - Configuration in app.ini: REQUEST_ID_HEADERS = X-Request-ID + - Print in log: 127.0.0.1:58384 - - [14/Feb/2023:16:33:51 +0800] "**test-id-123**" ... ### Log subsections (`log.name`, `log.name.*`) diff --git a/docs/content/doc/advanced/config-cheat-sheet.zh-cn.md b/docs/content/doc/advanced/config-cheat-sheet.zh-cn.md index aae64d97bac1..84186d0e9af1 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.zh-cn.md +++ b/docs/content/doc/advanced/config-cheat-sheet.zh-cn.md @@ -262,7 +262,22 @@ test01.xls: application/vnd.ms-excel; charset=binary - `ROOT_PATH`: 日志文件根目录。 - `MODE`: 日志记录模式,默认是为 `console`。如果要写到多个通道,用逗号分隔 -- `LEVEL`: 日志级别,默认为`Trace`。 +- `LEVEL`: 日志级别,默认为 `Trace`。 +- `DISABLE_ROUTER_LOG`: 关闭日志中的路由日志。 +- `ENABLE_ACCESS_LOG`: 是否开启 Access Log, 默认为 false。 +- `ACCESS_LOG_TEMPLATE`: `access.log` 输出内容的模板,默认模板:**`{{.Ctx.RemoteAddr}} - {{.Identity}} {{.Start.Format "[02/Jan/2006:15:04:05 -0700]" }} "{{.Ctx.Req.Method}} {{.Ctx.Req.URL.RequestURI}} {{.Ctx.Req.Proto}}" {{.ResponseWriter.Status}} {{.ResponseWriter.Size}} "{{.Ctx.Req.Referer}}\" \"{{.Ctx.Req.UserAgent}}"`** + 模板支持以下参数: + - `Ctx`: 请求上下文。 + - `Identity`: 登录用户名,默认: “`-`”。 + - `Start`: 请求开始时间。 + - `ResponseWriter`: + - `RequestID`: 从请求头中解析得到的与 `REQUEST_ID_HEADERS` 匹配的值,默认: “`-`”。 + - 一定要谨慎配置该模板,否则可能会引起panic. +- `REQUEST_ID_HEADERS`: 从 Request Header 中匹配指定 Key,并将匹配到的值输出到 `access.log` 中(需要在 `ACCESS_LOG_TEMPLATE` 中指定输出位置)。如果在该参数中配置多个 Key, 请用逗号分割,程序将按照配置的顺序进行匹配。 + - 示例: + - 请求头: X-Request-ID: **test-id-123** + - 配置文件: REQUEST_ID_HEADERS = X-Request-ID + - 日志输出: 127.0.0.1:58384 - - [14/Feb/2023:16:33:51 +0800] "**test-id-123**" ... ## Cron (`cron`) diff --git a/modules/context/access_log.go b/modules/context/access_log.go index 1aaba9dc2d5c..515682b64b0e 100644 --- a/modules/context/access_log.go +++ b/modules/context/access_log.go @@ -6,7 +6,9 @@ package context import ( "bytes" "context" + "fmt" "net/http" + "strings" "text/template" "time" @@ -20,13 +22,39 @@ type routerLoggerOptions struct { Start *time.Time ResponseWriter http.ResponseWriter Ctx map[string]interface{} + RequestID *string } var signedUserNameStringPointerKey interface{} = "signedUserNameStringPointerKey" +const keyOfRequestIDInTemplate = ".RequestID" + +// According to: +// TraceId: A valid trace identifier is a 16-byte array with at least one non-zero byte +// MD5 output is 16 or 32 bytes: md5-bytes is 16, md5-hex is 32 +// SHA1: similar, SHA1-bytes is 20, SHA1-hex is 40. +// UUID is 128-bit, 32 hex chars, 36 ASCII chars with 4 dashes +// So, we accept a Request ID with a maximum character length of 40 +const maxRequestIDByteLength = 40 + +func parseRequestIDFromRequestHeader(req *http.Request) string { + requestID := "-" + for _, key := range setting.Log.RequestIDHeaders { + if req.Header.Get(key) != "" { + requestID = req.Header.Get(key) + break + } + } + if len(requestID) > maxRequestIDByteLength { + requestID = fmt.Sprintf("%s...", requestID[:maxRequestIDByteLength]) + } + return requestID +} + // AccessLogger returns a middleware to log access logger func AccessLogger() func(http.Handler) http.Handler { logger := log.GetLogger("access") + needRequestID := len(setting.Log.RequestIDHeaders) > 0 && strings.Contains(setting.Log.AccessLogTemplate, keyOfRequestIDInTemplate) logTemplate, _ := template.New("log").Parse(setting.Log.AccessLogTemplate) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { @@ -34,6 +62,11 @@ func AccessLogger() func(http.Handler) http.Handler { identity := "-" r := req.WithContext(context.WithValue(req.Context(), signedUserNameStringPointerKey, &identity)) + var requestID string + if needRequestID { + requestID = parseRequestIDFromRequestHeader(req) + } + next.ServeHTTP(w, r) rw := w.(ResponseWriter) @@ -47,6 +80,7 @@ func AccessLogger() func(http.Handler) http.Handler { "RemoteAddr": req.RemoteAddr, "Req": req, }, + RequestID: &requestID, }) if err != nil { log.Error("Could not set up chi access logger: %v", err.Error()) diff --git a/modules/setting/log.go b/modules/setting/log.go index 5448650aadb9..dabdb543abdf 100644 --- a/modules/setting/log.go +++ b/modules/setting/log.go @@ -38,6 +38,7 @@ var Log struct { EnableAccessLog bool AccessLogTemplate string BufferLength int64 + RequestIDHeaders []string } // GetLogDescriptions returns a race safe set of descriptions @@ -153,6 +154,7 @@ func loadLogFrom(rootCfg ConfigProvider) { Log.AccessLogTemplate = sec.Key("ACCESS_LOG_TEMPLATE").MustString( `{{.Ctx.RemoteAddr}} - {{.Identity}} {{.Start.Format "[02/Jan/2006:15:04:05 -0700]" }} "{{.Ctx.Req.Method}} {{.Ctx.Req.URL.RequestURI}} {{.Ctx.Req.Proto}}" {{.ResponseWriter.Status}} {{.ResponseWriter.Size}} "{{.Ctx.Req.Referer}}\" \"{{.Ctx.Req.UserAgent}}"`, ) + Log.RequestIDHeaders = sec.Key("REQUEST_ID_HEADERS").Strings(",") // the `MustString` updates the default value, and `log.ACCESS` is used by `generateNamedLogger("access")` later _ = rootCfg.Section("log").Key("ACCESS").MustString("file") From 3de9e63fd04d61e08fcbdec035c9f138347d9f37 Mon Sep 17 00:00:00 2001 From: Zettat123 Date: Sat, 11 Mar 2023 00:42:38 +0800 Subject: [PATCH 04/13] Hide target selector if tag exists when creating new release (#23171) Close #22649. |status|screenshot| |-|-| |empty tag name|| |new tag|| |existing tag|| --- options/locale/locale_en-US.ini | 2 ++ templates/repo/release/new.tmpl | 33 +++++++++++++----------- web_src/js/features/repo-release.js | 39 +++++++++++++++++++++++++++-- web_src/js/index.js | 4 +-- 4 files changed, 60 insertions(+), 18 deletions(-) diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 095257b365fb..dccf184335b2 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -2290,6 +2290,8 @@ release.edit_subheader = Releases organize project versions. release.tag_name = Tag name release.target = Target release.tag_helper = Choose an existing tag or create a new tag. +release.tag_helper_new = New tag. This tag will be created from the target. +release.tag_helper_existing = Existing tag. release.title = Title release.content = Content release.prerelease_desc = Mark as Pre-Release diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index 37d7ca032196..d7c580fed969 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -20,22 +20,27 @@ {{.tag_name}}@{{.tag_target}} {{else}} - @ - diff --git a/web_src/js/features/repo-release.js b/web_src/js/features/repo-release.js index a061c6b23f83..a230d7765e22 100644 --- a/web_src/js/features/repo-release.js +++ b/web_src/js/features/repo-release.js @@ -3,7 +3,7 @@ import {attachTribute} from './tribute.js'; import {initCompMarkupContentPreviewTab} from './comp/MarkupContentPreview.js'; import {initEasyMDEImagePaste} from './comp/ImagePaste.js'; import {createCommentEasyMDE} from './comp/EasyMDE.js'; -import {hideElem} from '../utils/dom.js'; +import {hideElem, showElem} from '../utils/dom.js'; export function initRepoRelease() { $(document).on('click', '.remove-rel-attach', function() { @@ -14,8 +14,43 @@ export function initRepoRelease() { }); } +export function initRepoReleaseNew() { + const $repoReleaseNew = $('.repository.new.release'); + if (!$repoReleaseNew.length) return; -export function initRepoReleaseEditor() { + initTagNameEditor(); + initRepoReleaseEditor(); +} + +function initTagNameEditor() { + const el = document.getElementById('tag-name-editor'); + if (!el) return; + + const existingTags = JSON.parse(el.getAttribute('data-existing-tags')); + if (!Array.isArray(existingTags)) return; + + const defaultTagHelperText = el.getAttribute('data-tag-helper'); + const newTagHelperText = el.getAttribute('data-tag-helper-new'); + const existingTagHelperText = el.getAttribute('data-tag-helper-existing'); + + document.getElementById('tag-name').addEventListener('keyup', (e) => { + const value = e.target.value; + if (existingTags.includes(value)) { + // If the tag already exists, hide the target branch selector. + hideElem('#tag-target-selector'); + document.getElementById('tag-helper').innerText = existingTagHelperText; + } else { + showElem('#tag-target-selector'); + if (value) { + document.getElementById('tag-helper').innerText = newTagHelperText; + } else { + document.getElementById('tag-helper').innerText = defaultTagHelperText; + } + } + }); +} + +function initRepoReleaseEditor() { const $editor = $('.repository.new.release .content-editor'); if ($editor.length === 0) { return; diff --git a/web_src/js/index.js b/web_src/js/index.js index 611c09d2b8c1..6b4f4ef3ebe1 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -76,7 +76,7 @@ import { import {initViewedCheckboxListenerFor} from './features/pull-view-file.js'; import {initOrgTeamSearchRepoBox, initOrgTeamSettings} from './features/org-team.js'; import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.js'; -import {initRepoRelease, initRepoReleaseEditor} from './features/repo-release.js'; +import {initRepoRelease, initRepoReleaseNew} from './features/repo-release.js'; import {initRepoEditor} from './features/repo-editor.js'; import {initCompSearchUserBox} from './features/comp/SearchUserBox.js'; import {initInstall} from './features/install.js'; @@ -179,7 +179,7 @@ $(document).ready(() => { initRepoPullRequestAllowMaintainerEdit(); initRepoPullRequestReview(); initRepoRelease(); - initRepoReleaseEditor(); + initRepoReleaseNew(); initRepoSettingGitHook(); initRepoSettingSearchTeamBox(); initRepoSettingsCollaboration(); From f20bf2fe3b17d998940d5277db75c16b41d2a12c Mon Sep 17 00:00:00 2001 From: GiteaBot Date: Sat, 11 Mar 2023 00:15:59 +0000 Subject: [PATCH 05/13] [skip ci] Updated translations via Crowdin --- options/locale/locale_tr-TR.ini | 14 ++++----- options/locale/locale_zh-TW.ini | 52 +++++++++++++++++++-------------- 2 files changed, 37 insertions(+), 29 deletions(-) diff --git a/options/locale/locale_tr-TR.ini b/options/locale/locale_tr-TR.ini index c0e5bb220847..877d767ae068 100644 --- a/options/locale/locale_tr-TR.ini +++ b/options/locale/locale_tr-TR.ini @@ -334,7 +334,7 @@ non_local_account=Yerel olmayan kullanıcılar parolalarını Gitea web arayüz verify=Doğrula scratch_code=Çizgi kodu use_scratch_code=Bir çizgi kodu kullanınız -twofa_scratch_used=Çizgi kodunuzu kullandınız. İki aşamalı ayarlar sayfasına yönlendirildiniz, burada cihaz kaydınızı kaldırabilir veya yeni bir çizgi kodu oluşturabilirsiniz. +twofa_scratch_used=Geçici kodunuzu kullandınız. İki aşamalı ayarlar sayfasına yönlendirildiniz, burada aygıt kaydınızı kaldırabilir veya yeni bir geçici kod oluşturabilirsiniz. twofa_passcode_incorrect=Şifreniz yanlış. Aygıtınızı yanlış yerleştirdiyseniz, oturum açmak için çizgi kodunuzu kullanın. twofa_scratch_token_incorrect=Çizgi kodunuz doğru değildir. login_userpass=Oturum Aç @@ -736,12 +736,12 @@ unbind=Bağlantıyı Kaldır unbind_success=Sosyal hesabın bağlantısı Gitea hesabınızdan kaldırılmıştır. manage_access_token=Erişim Jetonlarını Yönet -generate_new_token=Yeni Jeton Üret +generate_new_token=Yeni Erişim Anahtarı Üret tokens_desc=Bu jetonlar Gitea API'sini kullanarak hesabınıza erişim sağlar. new_token_desc=Jeton kullanan uygulamalar hesabınıza tam erişime sahiptir. token_name=Jeton İsmi -generate_token=Jeton Üret -generate_token_success=Yeni bir jeton oluşturuldu. Tekrar gösterilmeyeceği için şimdi kopyalayın. +generate_token=Erişim Anahtarı Üret +generate_token_success=Yeni bir erişim anahtarı oluşturuldu. Tekrar gösterilmeyeceği için şimdi kopyalayın. generate_token_name_duplicate=%s zaten bir uygulama adı olarak kullanılmış. Lütfen yeni bir tane kullanın. delete_token=Sil access_token_deletion=Erişim Jetonunu Sil @@ -784,12 +784,12 @@ twofa_desc=İki faktörlü kimlik doğrulama, hesabınızın güvenliğini artı twofa_is_enrolled=Hesabınız şu anda iki faktörlü kimlik doğrulaması içinde kaydedilmiş. twofa_not_enrolled=Hesabınız şu anda iki faktörlü kimlik doğrulaması içinde kaydedilmemiş. twofa_disable=İki Aşamalı Doğrulamayı Devre Dışı Bırak -twofa_scratch_token_regenerate=Kazıma Belirtecini Yenile -twofa_scratch_token_regenerated=Kazıma belirteciniz şimdi %s. Güvenli bir yerde saklayın. +twofa_scratch_token_regenerate=Geçici Kodu Yeniden Üret +twofa_scratch_token_regenerated=Geçici kodunuz artık %s. Güvenilir bir yerde saklayın. twofa_enroll=İki Faktörlü Kimlik Doğrulamaya Kaydolun twofa_disable_note=Gerekirse iki faktörlü kimlik doğrulamayı devre dışı bırakabilirsiniz. twofa_disable_desc=İki faktörlü kimlik doğrulamayı devre dışı bırakmak hesabınızı daha az güvenli hale getirir. Devam edilsin mi? -regenerate_scratch_token_desc=Karalama belirtecinizi yanlış yerleştirdiyseniz veya oturum açmak için kullandıysanız, buradan sıfırlayabilirsiniz. +regenerate_scratch_token_desc=Geçici kodunuzu kaybettiyseniz veya oturum açmak için kullandıysanız, buradan sıfırlayabilirsiniz. twofa_disabled=İki faktörlü kimlik doğrulama devre dışı bırakıldı. scan_this_image=Kim doğrulama uygulamanızla bu görüntüyü tarayın: or_enter_secret=Veya gizli şeyi girin: %s diff --git a/options/locale/locale_zh-TW.ini b/options/locale/locale_zh-TW.ini index 51610f4e82d7..fa39225c1b89 100644 --- a/options/locale/locale_zh-TW.ini +++ b/options/locale/locale_zh-TW.ini @@ -54,7 +54,7 @@ mirror=鏡像 new_repo=新增儲存庫 new_migrate=遷移外部儲存庫 new_mirror=新鏡像 -new_fork=新增儲存庫 fork +new_fork=新增儲存庫 Fork new_org=新增組織 new_project=新增專案 new_project_column=新增欄位 @@ -476,6 +476,8 @@ url_error=`'%s' 是無效的 URL。` include_error=` 必須包含子字串「%s」。 glob_pattern_error=` glob 比對模式無效:%s.` regex_pattern_error=` 正規表示式模式無效:%s.` +username_error=`只能包含英文字母數字 ('0-9'、'a-z'、'A-Z')、破折號 ('-')、底線 ('_')、句點 ('.'),不能以非英文字母數字開頭或結尾,也不允許連續的非英文字母數字。` +invalid_group_team_map_error=` 對應無效: %s` unknown_error=未知錯誤: captcha_incorrect=驗證碼不正確。 password_not_match=密碼錯誤。 @@ -521,11 +523,11 @@ must_use_public_key=您提供的金鑰是私有金鑰,請勿上傳您的私有 unable_verify_ssh_key=無法驗證 SSH 密鑰 auth_failed=授權認證失敗:%v -still_own_repo=此帳戶仍然擁有一個或多個儲存庫,您必須先刪除或轉移它們。 -still_has_org=此帳戶仍是一個或多個組織的成員,您必須先離開它們。 -still_own_packages=您的帳戶擁有一個或多個套件,請先刪除他們。 -org_still_own_repo=該組織仍然是某些儲存庫的擁有者,您必須先轉移或刪除它們才能執行刪除組織! -org_still_own_packages=此組織擁有一個或多個套件,請先刪除他們。 +still_own_repo=您的帳戶擁有一個以上的儲存庫,請先刪除或轉移它們。 +still_has_org=您的帳戶是一個或多個組織的成員,請先離開它們。 +still_own_packages=您的帳戶擁有一個以上的套件,請先刪除它們。 +org_still_own_repo=此組織仍然擁有一個以上的儲存庫,請先刪除或轉移它們。 +org_still_own_packages=此組織仍然擁有一個以上的套件,請先刪除它們。 target_branch_not_exist=目標分支不存在 @@ -1132,6 +1134,7 @@ editor.commit_directly_to_this_branch=直接提交到 命 pulls.merge_instruction_step1_desc=在您的儲存庫中切換到新分支並測試變更。 pulls.merge_instruction_step2_desc=合併變更並更新到 Gitea。 pulls.clear_merge_message=清除合併訊息 +pulls.clear_merge_message_hint=清除合併訊息將僅移除提交訊息內容,留下產生的 git 結尾,如「Co-Authored-By …」。 pulls.auto_merge_button_when_succeed=(當通過檢查後) pulls.auto_merge_when_succeed=通過所有檢查後自動合併 @@ -2619,7 +2623,7 @@ users.still_own_repo=這個使用者還擁有一個或更多的儲存庫。請 users.still_has_org=此使用者是組織的成員。請先將他從組織中移除。 users.purge=清除使用者 users.purge_help=強制刪除使用者和他擁有的所有儲存庫、組織、套件,所有留言也會被刪除。 -users.still_own_packages=此使用者擁有一個或多個套件,請先刪除這些套件。 +users.still_own_packages=此使用者仍然擁有一個以上的套件,請先刪除這些套件。 users.deletion_success=使用者帳戶已被刪除。 users.reset_2fa=重設兩步驟驗證 users.list_status_filter.menu_text=篩選 @@ -2649,7 +2653,7 @@ emails.change_email_header=更新電子信箱屬性 emails.change_email_text=您確定要更新這個電子信箱? orgs.org_manage_panel=組織管理 -orgs.name=組織名稱 +orgs.name=名稱 orgs.teams=團隊數 orgs.members=成員數 orgs.new_orga=新增組織 @@ -2658,7 +2662,7 @@ repos.repo_manage_panel=儲存庫管理 repos.unadopted=未接管的儲存庫 repos.unadopted.no_more=找不到其他未接管的儲存庫 repos.owner=擁有者 -repos.name=儲存庫名稱 +repos.name=名稱 repos.private=私有 repos.watches=關注數 repos.stars=星號數 @@ -2690,8 +2694,8 @@ systemhooks.update_webhook=更新系統 Webhook auths.auth_manage_panel=認證來源管理 auths.new=新增認證來源 -auths.name=認證名稱 -auths.type=認證類型 +auths.name=名稱 +auths.type=類型 auths.enabled=已啟用 auths.syncenabled=啟用使用者同步 auths.updated=最後更新時間 @@ -2762,6 +2766,7 @@ auths.oauth2_required_claim_value_helper=填寫此名稱以限制 Claim 中有 auths.oauth2_group_claim_name=Claim 名稱提供群組名稱給此來源。(選用) auths.oauth2_admin_group=管理員使用者的群組 Claim 值。(選用 - 需要上面的 Claim 名稱) auths.oauth2_restricted_group=受限制使用者的群組 Claim 值。(選用 - 需要上面的 Claim 名稱) +auths.oauth2_map_group_to_team=將已 Claim 的群組對應到組織團隊。(選用 - 需要上述 Claim 名稱) auths.oauth2_map_group_to_team_removal=如果使用者不屬於相對應的群組,將使用者從已同步的團隊移除。 auths.enable_auto_register=允許授權用戶自動註冊 auths.sspi_auto_create_users=自動建立使用者 @@ -2843,7 +2848,7 @@ config.lfs_http_auth_expiry=LFS HTTP 驗證有效時間 config.db_config=資料庫組態 config.db_type=資料庫類型 config.db_host=主機地址 -config.db_name=資料庫名稱 +config.db_name=名稱 config.db_user=使用者名稱 config.db_schema=結構描述 config.db_ssl_mode=SSL @@ -2946,7 +2951,7 @@ config.get_setting_failed=讀取設定值 %s 失敗 config.set_setting_failed=寫入設定值 %s 失敗 monitor.cron=Cron 任務 -monitor.name=任務名稱 +monitor.name=名稱 monitor.schedule=任務安排 monitor.next=下次執行時間 monitor.previous=上次執行時間 @@ -3031,7 +3036,7 @@ notices.deselect_all=取消所有選取 notices.inverse_selection=反向選取 notices.delete_selected=刪除選取項 notices.delete_all=刪除所有提示 -notices.type=提示類型 +notices.type=類型 notices.type_1=儲存庫 notices.type_2=任務 notices.desc=描述 @@ -3196,7 +3201,7 @@ generic.documentation=關於通用 registry 的詳情請參閱說明文件。 -maven.registry=在您的 pom.xml 檔設定此註冊中心: +maven.registry=在您專案的 pom.xml 檔設定此註冊中心: maven.install=若要使用此套件,請在您 pom.xml 檔的 dependencies 段落加入下列內容: maven.install2=透過下列命令執行: maven.download=透過下列命令下載相依性: @@ -3205,7 +3210,7 @@ nuget.registry=透過下列命令設定此註冊中心: nuget.install=執行下列命令以使用 NuGet 安裝此套件: nuget.documentation=關於 NuGet registry 的詳情請參閱說明文件。 nuget.dependency.framework=目標框架 -npm.registry=在您的 .npmrc 檔設定此註冊中心: +npm.registry=在您專案的 .npmrc 檔設定此註冊中心: npm.install=執行下列命令以使用 npm 安裝此套件: npm.install2=或將它加到 package.json 檔: npm.documentation=關於 npm registry 的詳情請參閱說明文件。 @@ -3241,6 +3246,7 @@ settings.delete.success=已刪除該套件。 settings.delete.error=刪除套件失敗。 owner.settings.cargo.title=Cargo Registry 索引 owner.settings.cargo.initialize=初始化索引 +owner.settings.cargo.initialize.description=使用 Cargo Registry 時需要一個特別的 Git 儲存庫作為索引。您可以在此使用必要的設定建立和重建它。 owner.settings.cargo.initialize.error=初始化 Cargo 索引失敗: %v owner.settings.cargo.initialize.success=成功建立了 Cargo 索引。 owner.settings.cargo.rebuild=重建索引 @@ -3252,6 +3258,7 @@ owner.settings.cleanuprules.add=加入清理規則 owner.settings.cleanuprules.edit=編輯清理規則 owner.settings.cleanuprules.none=沒有可用的清理規則。閱讀文件以了解更多。 owner.settings.cleanuprules.preview=清理規則預覽 +owner.settings.cleanuprules.preview.overview=已排定要移除 %d 個套件。 owner.settings.cleanuprules.preview.none=清理規則不符合任何套件。 owner.settings.cleanuprules.enabled=已啟用 owner.settings.cleanuprules.pattern_full_match=將比對規則套用到完整的套件名稱 @@ -3275,8 +3282,9 @@ secrets=Secret description=Secret 會被傳給特定的 Action,其他情況無法讀取。 none=還沒有 Secret。 value=值 -name=組織名稱 +name=名稱 creation=加入 Secret +creation.name_placeholder=不區分大小寫,只能包含英文字母、數字、底線 ('_'),不能以 GITEA_ 或 GITHUB_ 開頭。 creation.value_placeholder=輸入任何內容,頭尾的空白都會被忽略。 creation.success=已加入 Secret「%s」。 creation.failed=加入 Secret 失敗。 @@ -3305,8 +3313,8 @@ runners.new=建立 Runner runners.new_notice=如何啟動 Runner runners.status=狀態 runners.id=ID -runners.name=組織名稱 -runners.owner_type=認證類型 +runners.name=名稱 +runners.owner_type=類型 runners.description=組織描述 runners.labels=標籤 runners.last_online=最後上線時間 From 75022f8b1a513ca2fd7ca66a2f05ecc49e2f1460 Mon Sep 17 00:00:00 2001 From: wxiaoguang Date: Sat, 11 Mar 2023 18:47:09 +0800 Subject: [PATCH 06/13] Refactor branch/tag selector dropdown (first step) (#23394) Follow: * #23345 The branch/tag selector dropdown mixes jQuery/Fomantic UI/Vue together, it's very diffcult to maintain and causes unfixable a11y problems. It also causes problems like #19851 #21314 #21952 This PR is the first step for the refactoring, move `data-` attributes to JS object and use Vue data as much as possible. The old selector `'.choose.reference .dropdown'` was also wrong, it hits `
` and would cause undefined behaviors. I have done some quick tests and it works. After this PR gets merged, I will move the code into a Vue SFC in next PR. ![image](https://user-images.githubusercontent.com/2114189/224099638-378a8a86-0865-47d1-bcba-f972506374c7.png) ![image](https://user-images.githubusercontent.com/2114189/224099690-70276cf5-b1e4-404a-b0c6-582448abf40e.png) --------- Co-authored-by: techknowlogick --- templates/repo/branch_dropdown.tmpl | 128 ++++++++++-------- templates/repo/commit_page.tmpl | 8 +- .../js/components/RepoBranchTagDropdown.js | 67 ++++----- web_src/js/features/repo-legacy.js | 2 +- 4 files changed, 101 insertions(+), 104 deletions(-) diff --git a/templates/repo/branch_dropdown.tmpl b/templates/repo/branch_dropdown.tmpl index 89d65146a9d6..8e81373aec04 100644 --- a/templates/repo/branch_dropdown.tmpl +++ b/templates/repo/branch_dropdown.tmpl @@ -2,101 +2,111 @@ {{$defaultBranch := $.root.BranchName}}{{if and .root.IsViewTag (not .noTag)}}{{$defaultBranch = .root.TagName}}{{end}}{{if eq $defaultBranch ""}}{{$defaultBranch = $.root.Repository.DefaultBranch}}{{end}} {{$type := ""}}{{if and .root.IsViewTag (not .noTag)}}{{$type = "tag"}}{{else if .root.IsViewBranch}}{{$type = "branch"}}{{else}}{{$type = "tree"}}{{end}} {{$showBranchesInDropdown := not .root.HideBranchesInDropdown}} + + +
- -{{if or .CanWriteIssues .CanWritePulls}} +{{if $.CanWriteProjects}}