diff options
32 files changed, 440 insertions, 68 deletions
diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index fa6a9e3fac..e7fe9206ed 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -651,9 +651,15 @@ PATH = ;DEFAULT_ALLOW_CREATE_ORGANIZATION = true ;; ;; Either "public", "limited" or "private", default is "public" -;; Limited is for signed user only -;; Private is only for member of the organization -;; Public is for everyone +;; Limited is for users visible only to signed users +;; Private is for users visible only to members of their organizations +;; Public is for users visible for everyone +;DEFAULT_USER_VISIBILITY = public +;; +;; Either "public", "limited" or "private", default is "public" +;; Limited is for organizations visible only to signed users +;; Private is for organizations visible only to members of the organization +;; Public is for organizations visible to everyone ;DEFAULT_ORG_VISIBILITY = public ;; ;; Default value for DefaultOrgMemberVisible 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 aa9eb7e0ca..21359dcab1 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -512,6 +512,7 @@ relation to port exhaustion. - `SHOW_MILESTONES_DASHBOARD_PAGE`: **true** Enable this to show the milestones dashboard page - a view of all the user's milestones - `AUTO_WATCH_NEW_REPOS`: **true**: Enable this to let all organisation users watch new repos when they are created - `AUTO_WATCH_ON_CHANGES`: **false**: Enable this to make users watch a repository after their first commit to it +- `DEFAULT_USER_VISIBILITY`: **public**: Set default visibility mode for users, either "public", "limited" or "private". - `DEFAULT_ORG_VISIBILITY`: **public**: Set default visibility mode for organisations, either "public", "limited" or "private". - `DEFAULT_ORG_MEMBER_VISIBLE`: **false** True will make the membership of the users visible when added to the organisation. - `ALLOW_ONLY_INTERNAL_REGISTRATION`: **false** Set to true to force registration only via gitea. diff --git a/integrations/api_user_search_test.go b/integrations/api_user_search_test.go index c5295fbba5..f7349827e5 100644 --- a/integrations/api_user_search_test.go +++ b/integrations/api_user_search_test.go @@ -59,3 +59,34 @@ func TestAPIUserSearchNotLoggedIn(t *testing.T) { } } } + +func TestAPIUserSearchAdminLoggedInUserHidden(t *testing.T) { + defer prepareTestEnv(t)() + adminUsername := "user1" + session := loginUser(t, adminUsername) + token := getTokenForLoggedInUser(t, session) + query := "user31" + req := NewRequestf(t, "GET", "/api/v1/users/search?token=%s&q=%s", token, query) + req.SetBasicAuth(token, "x-oauth-basic") + resp := session.MakeRequest(t, req, http.StatusOK) + + var results SearchResults + DecodeJSON(t, resp, &results) + assert.NotEmpty(t, results.Data) + for _, user := range results.Data { + assert.Contains(t, user.UserName, query) + assert.NotEmpty(t, user.Email) + assert.EqualValues(t, "private", user.Visibility) + } +} + +func TestAPIUserSearchNotLoggedInUserHidden(t *testing.T) { + defer prepareTestEnv(t)() + query := "user31" + req := NewRequestf(t, "GET", "/api/v1/users/search?q=%s", query) + resp := MakeRequest(t, req, http.StatusOK) + + var results SearchResults + DecodeJSON(t, resp, &results) + assert.Empty(t, results.Data) +} diff --git a/models/fixtures/user.yml b/models/fixtures/user.yml index d903a7942f..850ee4041d 100644 --- a/models/fixtures/user.yml +++ b/models/fixtures/user.yml @@ -508,7 +508,6 @@ num_repos: 0 is_active: true - - id: 30 lower_name: user30 @@ -525,3 +524,20 @@ avatar_email: user30@example.com num_repos: 2 is_active: true + +- + id: 31 + lower_name: user31 + name: user31 + full_name: "user31" + email: user31@example.com + passwd_hash_algo: argon2 + passwd: a3d5fcd92bae586c2e3dbe72daea7a0d27833a8d0227aa1704f4bbd775c1f3b03535b76dd93b0d4d8d22a519dca47df1547b # password + type: 0 # individual + salt: ZogKvWdyEx + is_admin: false + visibility: 2 + avatar: avatar31 + avatar_email: user31@example.com + num_repos: 0 + is_active: true diff --git a/models/org.go b/models/org.go index 7f9e3cce5b..073b26c2f8 100644 --- a/models/org.go +++ b/models/org.go @@ -455,22 +455,22 @@ func getOwnedOrgsByUserID(sess *xorm.Session, userID int64) ([]*User, error) { Find(&orgs) } -// HasOrgVisible tells if the given user can see the given org -func HasOrgVisible(org, user *User) bool { - return hasOrgVisible(x, org, user) +// HasOrgOrUserVisible tells if the given user can see the given org or user +func HasOrgOrUserVisible(org, user *User) bool { + return hasOrgOrUserVisible(x, org, user) } -func hasOrgVisible(e Engine, org, user *User) bool { +func hasOrgOrUserVisible(e Engine, orgOrUser, user *User) bool { // Not SignedUser if user == nil { - return org.Visibility == structs.VisibleTypePublic + return orgOrUser.Visibility == structs.VisibleTypePublic } - if user.IsAdmin { + if user.IsAdmin || orgOrUser.ID == user.ID { return true } - if (org.Visibility == structs.VisibleTypePrivate || user.IsRestricted) && !org.hasMemberWithUserID(e, user.ID) { + if (orgOrUser.Visibility == structs.VisibleTypePrivate || user.IsRestricted) && !orgOrUser.hasMemberWithUserID(e, user.ID) { return false } return true @@ -483,7 +483,7 @@ func HasOrgsVisible(orgs []*User, user *User) bool { } for _, org := range orgs { - if HasOrgVisible(org, user) { + if HasOrgOrUserVisible(org, user) { return true } } diff --git a/models/org_test.go b/models/org_test.go index bed7a6eb86..e494e502dd 100644 --- a/models/org_test.go +++ b/models/org_test.go @@ -586,9 +586,9 @@ func TestHasOrgVisibleTypePublic(t *testing.T) { assert.NoError(t, CreateOrganization(org, owner)) org = AssertExistsAndLoadBean(t, &User{Name: org.Name, Type: UserTypeOrganization}).(*User) - test1 := HasOrgVisible(org, owner) - test2 := HasOrgVisible(org, user3) - test3 := HasOrgVisible(org, nil) + test1 := HasOrgOrUserVisible(org, owner) + test2 := HasOrgOrUserVisible(org, user3) + test3 := HasOrgOrUserVisible(org, nil) assert.True(t, test1) // owner of org assert.True(t, test2) // user not a part of org assert.True(t, test3) // logged out user @@ -609,9 +609,9 @@ func TestHasOrgVisibleTypeLimited(t *testing.T) { assert.NoError(t, CreateOrganization(org, owner)) org = AssertExistsAndLoadBean(t, &User{Name: org.Name, Type: UserTypeOrganization}).(*User) - test1 := HasOrgVisible(org, owner) - test2 := HasOrgVisible(org, user3) - test3 := HasOrgVisible(org, nil) + test1 := HasOrgOrUserVisible(org, owner) + test2 := HasOrgOrUserVisible(org, user3) + test3 := HasOrgOrUserVisible(org, nil) assert.True(t, test1) // owner of org assert.True(t, test2) // user not a part of org assert.False(t, test3) // logged out user @@ -632,9 +632,9 @@ func TestHasOrgVisibleTypePrivate(t *testing.T) { assert.NoError(t, CreateOrganization(org, owner)) org = AssertExistsAndLoadBean(t, &User{Name: org.Name, Type: UserTypeOrganization}).(*User) - test1 := HasOrgVisible(org, owner) - test2 := HasOrgVisible(org, user3) - test3 := HasOrgVisible(org, nil) + test1 := HasOrgOrUserVisible(org, owner) + test2 := HasOrgOrUserVisible(org, user3) + test3 := HasOrgOrUserVisible(org, nil) assert.True(t, test1) // owner of org assert.False(t, test2) // user not a part of org assert.False(t, test3) // logged out user diff --git a/models/repo.go b/models/repo.go index 4ce3d4839b..92d8427fab 100644 --- a/models/repo.go +++ b/models/repo.go @@ -585,8 +585,7 @@ func (repo *Repository) getReviewers(e Engine, doerID, posterID int64) ([]*User, var users []*User - if repo.IsPrivate || - (repo.Owner.IsOrganization() && repo.Owner.Visibility == api.VisibleTypePrivate) { + if repo.IsPrivate || repo.Owner.Visibility == api.VisibleTypePrivate { // This a private repository: // Anyone who can read the repository is a requestable reviewer if err := e. diff --git a/models/repo_permission.go b/models/repo_permission.go index 138613b2e9..4f043a58cc 100644 --- a/models/repo_permission.go +++ b/models/repo_permission.go @@ -176,9 +176,9 @@ func getUserRepoPermission(e Engine, repo *Repository, user *User) (perm Permiss return } - // Prevent strangers from checking out public repo of private orginization - // Allow user if they are collaborator of a repo within a private orginization but not a member of the orginization itself - if repo.Owner.IsOrganization() && !hasOrgVisible(e, repo.Owner, user) && !isCollaborator { + // Prevent strangers from checking out public repo of private orginization/users + // Allow user if they are collaborator of a repo within a private user or a private organization but not a member of the organization itself + if !hasOrgOrUserVisible(e, repo.Owner, user) && !isCollaborator { perm.AccessMode = AccessModeNone return } diff --git a/models/user.go b/models/user.go index 5998341422..221c840a7f 100644 --- a/models/user.go +++ b/models/user.go @@ -432,6 +432,62 @@ func (u *User) IsPasswordSet() bool { return len(u.Passwd) != 0 } +// IsVisibleToUser check if viewer is able to see user profile +func (u *User) IsVisibleToUser(viewer *User) bool { + return u.isVisibleToUser(x, viewer) +} + +func (u *User) isVisibleToUser(e Engine, viewer *User) bool { + if viewer != nil && viewer.IsAdmin { + return true + } + + switch u.Visibility { + case structs.VisibleTypePublic: + return true + case structs.VisibleTypeLimited: + if viewer == nil || viewer.IsRestricted { + return false + } + return true + case structs.VisibleTypePrivate: + if viewer == nil || viewer.IsRestricted { + return false + } + + // If they follow - they see each over + follower := IsFollowing(u.ID, viewer.ID) + if follower { + return true + } + + // Now we need to check if they in some organization together + count, err := x.Table("team_user"). + Where( + builder.And( + builder.Eq{"uid": viewer.ID}, + builder.Or( + builder.Eq{"org_id": u.ID}, + builder.In("org_id", + builder.Select("org_id"). + From("team_user", "t2"). + Where(builder.Eq{"uid": u.ID}))))). + Count(new(TeamUser)) + if err != nil { + return false + } + + if count < 0 { + // No common organization + return false + } + + // they are in an organization together + return true + } + return false +} + // IsOrganization returns true if user is actually a organization. func (u *User) IsOrganization() bool { return u.Type == UserTypeOrganization @@ -796,8 +852,13 @@ func IsUsableUsername(name string) error { return isUsableName(reservedUsernames, reservedUserPatterns, name) } +// CreateUserOverwriteOptions are an optional options who overwrite system defaults on user creation +type CreateUserOverwriteOptions struct { + Visibility structs.VisibleType +} + // CreateUser creates record of a new user. -func CreateUser(u *User) (err error) { +func CreateUser(u *User, overwriteDefault ...*CreateUserOverwriteOptions) (err error) { if err = IsUsableUsername(u.Name); err != nil { return err } @@ -831,8 +892,6 @@ func CreateUser(u *User) (err error) { return ErrEmailAlreadyUsed{u.Email} } - u.KeepEmailPrivate = setting.Service.DefaultKeepEmailPrivate - u.LowerName = strings.ToLower(u.Name) u.AvatarEmail = u.Email if u.Rands, err = GetUserSalt(); err != nil { @@ -841,10 +900,18 @@ func CreateUser(u *User) (err error) { if err = u.SetPassword(u.Passwd); err != nil { return err } + + // set system defaults + u.KeepEmailPrivate = setting.Service.DefaultKeepEmailPrivate + u.Visibility = setting.Service.DefaultUserVisibilityMode u.AllowCreateOrganization = setting.Service.DefaultAllowCreateOrganization && !setting.Admin.DisableRegularOrgCreation u.EmailNotificationsPreference = setting.Admin.DefaultEmailNotification u.MaxRepoCreation = -1 u.Theme = setting.UI.DefaultTheme + // overwrite defaults if set + if len(overwriteDefault) != 0 && overwriteDefault[0] != nil { + u.Visibility = overwriteDefault[0].Visibility + } if _, err = sess.Insert(u); err != nil { return err @@ -1527,10 +1594,9 @@ func (opts *SearchUserOptions) toConds() builder.Cond { cond = cond.And(keywordCond) } + // If visibility filtered if len(opts.Visible) > 0 { cond = cond.And(builder.In("visibility", opts.Visible)) - } else { - cond = cond.And(builder.In("visibility", structs.VisibleTypePublic)) } if opts.Actor != nil { @@ -1543,16 +1609,27 @@ func (opts *SearchUserOptions) toConds() builder.Cond { exprCond = builder.Expr("org_user.org_id = \"user\".id") } - var accessCond builder.Cond - if !opts.Actor.IsRestricted { - accessCond = builder.Or( - builder.In("id", builder.Select("org_id").From("org_user").LeftJoin("`user`", exprCond).Where(builder.And(builder.Eq{"uid": opts.Actor.ID}, builder.Eq{"visibility": structs.VisibleTypePrivate}))), - builder.In("visibility", structs.VisibleTypePublic, structs.VisibleTypeLimited)) - } else { - // restricted users only see orgs they are a member of - accessCond = builder.In("id", builder.Select("org_id").From("org_user").LeftJoin("`user`", exprCond).Where(builder.And(builder.Eq{"uid": opts.Actor.ID}))) + // If Admin - they see all users! + if !opts.Actor.IsAdmin { + // Force visiblity for privacy + var accessCond builder.Cond + if !opts.Actor.IsRestricted { + accessCond = builder.Or( + builder.In("id", builder.Select("org_id").From("org_user").LeftJoin("`user`", exprCond).Where(builder.And(builder.Eq{"uid": opts.Actor.ID}, builder.Eq{"visibility": structs.VisibleTypePrivate}))), + builder.In("visibility", structs.VisibleTypePublic, structs.VisibleTypeLimited)) + } else { + // restricted users only see orgs they are a member of + accessCond = builder.In("id", builder.Select("org_id").From("org_user").LeftJoin("`user`", exprCond).Where(builder.And(builder.Eq{"uid": opts.Actor.ID}))) + } + // Don't forget about self + accessCond = accessCond.Or(builder.Eq{"id": opts.Actor.ID}) + cond = cond.And(accessCond) } - cond = cond.And(accessCond) + + } else { + // Force visiblity for privacy + // Not logged in - only public users + cond = cond.And(builder.In("visibility", structs.VisibleTypePublic)) } if opts.UID > 0 { diff --git a/modules/convert/user.go b/modules/convert/user.go index 894be3bd44..164ffb71fd 100644 --- a/modules/convert/user.go +++ b/modules/convert/user.go @@ -62,10 +62,14 @@ func toUser(user *models.User, signed, authed bool) *api.User { Following: user.NumFollowing, StarredRepos: user.NumStars, } + + result.Visibility = user.Visibility.String() + // hide primary email if API caller is anonymous or user keep email private if signed && (!user.KeepEmailPrivate || authed) { result.Email = user.Email } + // only site admin will get these information and possibly user himself if authed { result.IsAdmin = user.IsAdmin diff --git a/modules/convert/user_test.go b/modules/convert/user_test.go index 7837910ffe..679c4f9894 100644 --- a/modules/convert/user_test.go +++ b/modules/convert/user_test.go @@ -8,6 +8,7 @@ import ( "testing" "code.gitea.io/gitea/models" + api "code.gitea.io/gitea/modules/structs" "github.com/stretchr/testify/assert" ) @@ -27,4 +28,11 @@ func TestUser_ToUser(t *testing.T) { apiUser = toUser(user1, false, false) assert.False(t, apiUser.IsAdmin) + assert.EqualValues(t, api.VisibleTypePublic.String(), apiUser.Visibility) + + user31 := models.AssertExistsAndLoadBean(t, &models.User{ID: 31, IsAdmin: false, Visibility: api.VisibleTypePrivate}).(*models.User) + + apiUser = toUser(user31, true, true) + assert.False(t, apiUser.IsAdmin) + assert.EqualValues(t, api.VisibleTypePrivate.String(), apiUser.Visibility) } diff --git a/modules/setting/service.go b/modules/setting/service.go index bd70c7e6eb..3f689212f3 100644 --- a/modules/setting/service.go +++ b/modules/setting/service.go @@ -15,6 +15,8 @@ import ( // Service settings var Service struct { + DefaultUserVisibility string + DefaultUserVisibilityMode structs.VisibleType DefaultOrgVisibility string DefaultOrgVisibilityMode structs.VisibleType ActiveCodeLives int @@ -118,6 +120,8 @@ func newService() { Service.EnableUserHeatmap = sec.Key("ENABLE_USER_HEATMAP").MustBool(true) Service.AutoWatchNewRepos = sec.Key("AUTO_WATCH_NEW_REPOS").MustBool(true) Service.AutoWatchOnChanges = sec.Key("AUTO_WATCH_ON_CHANGES").MustBool(false) + Service.DefaultUserVisibility = sec.Key("DEFAULT_USER_VISIBILITY").In("public", structs.ExtractKeysFromMapString(structs.VisibilityModes)) + Service.DefaultUserVisibilityMode = structs.VisibilityModes[Service.DefaultUserVisibility] Service.DefaultOrgVisibility = sec.Key("DEFAULT_ORG_VISIBILITY").In("public", structs.ExtractKeysFromMapString(structs.VisibilityModes)) Service.DefaultOrgVisibilityMode = structs.VisibilityModes[Service.DefaultOrgVisibility] Service.DefaultOrgMemberVisible = sec.Key("DEFAULT_ORG_MEMBER_VISIBLE").MustBool() diff --git a/modules/structs/admin_user.go b/modules/structs/admin_user.go index 5da4e9608b..facf16a395 100644 --- a/modules/structs/admin_user.go +++ b/modules/structs/admin_user.go @@ -19,6 +19,7 @@ type CreateUserOption struct { Password string `json:"password" binding:"Required;MaxSize(255)"` MustChangePassword *bool `json:"must_change_password"` SendNotify bool `json:"send_notify"` + Visibility string `json:"visibility" binding:"In(,public,limited,private)"` } // EditUserOption edit user options @@ -43,4 +44,5 @@ type EditUserOption struct { ProhibitLogin *bool `json:"prohibit_login"` AllowCreateOrganization *bool `json:"allow_create_organization"` Restricted *bool `json:"restricted"` + Visibility string `json:"visibility" binding:"In(,public,limited,private)"` } diff --git a/modules/structs/user.go b/modules/structs/user.go index 0d8b0300c3..a3c8f0c32a 100644 --- a/modules/structs/user.go +++ b/modules/structs/user.go @@ -43,6 +43,8 @@ type User struct { Website string `json:"website"` // the user's description Description string `json:"description"` + // User visibility level option: public, limited, private + Visibility string `json:"visibility"` // user counts Followers int `json:"followers_count"` diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 4a79ffa7eb..e0ece8f9f0 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -724,6 +724,14 @@ email_notifications.onmention = Only Email on Mention email_notifications.disable = Disable Email Notifications email_notifications.submit = Set Email Preference +visibility = User visibility +visibility.public = Public +visibility.public_tooltip = Visible to all users +visibility.limited = Limited +visibility.limited_tooltip = Visible to logged in users only +visibility.private = Private +visibility.private_tooltip = Visible only to organization members + [repo] new_repo_helper = A repository contains all project files, including revision history. Already have it elsewhere? <a href="%s">Migrate repository.</a> owner = Owner diff --git a/routers/api/v1/admin/user.go b/routers/api/v1/admin/user.go index 4bbe7f77ba..6bc9b849b1 100644 --- a/routers/api/v1/admin/user.go +++ b/routers/api/v1/admin/user.go @@ -66,6 +66,7 @@ func CreateUser(ctx *context.APIContext) { // "422": // "$ref": "#/responses/validationError" form := web.GetForm(ctx).(*api.CreateUserOption) + u := &models.User{ Name: form.Username, FullName: form.FullName, @@ -97,7 +98,15 @@ func CreateUser(ctx *context.APIContext) { ctx.Error(http.StatusBadRequest, "PasswordPwned", errors.New("PasswordPwned")) return } - if err := models.CreateUser(u); err != nil { + + var overwriteDefault *models.CreateUserOverwriteOptions + if form.Visibility != "" { + overwriteDefault = &models.CreateUserOverwriteOptions{ + Visibility: api.VisibilityModes[form.Visibility], + } + } + + if err := models.CreateUser(u, overwriteDefault); err != nil { if models.IsErrUserAlreadyExist(err) || models.IsErrEmailAlreadyUsed(err) || models.IsErrNameReserved(err) || @@ -209,6 +218,9 @@ func EditUser(ctx *context.APIContext) { if form.Active != nil { u.IsActive = *form.Active } + if len(form.Visibility) != 0 { + u.Visibility = api.VisibilityModes[form.Visibility] + } if form.Admin != nil { u.IsAdmin = *form.Admin } @@ -395,6 +407,7 @@ func GetAllUsers(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) users, maxResults, err := models.SearchUsers(&models.SearchUserOptions{ + Actor: ctx.User, Type: models.UserTypeIndividual, OrderBy: models.SearchOrderByAlphabetically, ListOptions: listOptions, diff --git a/routers/api/v1/org/org.go b/routers/api/v1/org/org.go index f4a634f4d5..5c16594f89 100644 --- a/routers/api/v1/org/org.go +++ b/routers/api/v1/org/org.go @@ -225,8 +225,8 @@ func Get(ctx *context.APIContext) { // "200": // "$ref": "#/responses/Organization" - if !models.HasOrgVisible(ctx.Org.Organization, ctx.User) { - ctx.NotFound("HasOrgVisible", nil) + if !models.HasOrgOrUserVisible(ctx.Org.Organization, ctx.User) { + ctx.NotFound("HasOrgOrUserVisible", nil) return } ctx.JSON(http.StatusOK, convert.ToOrganization(ctx.Org.Organization)) diff --git a/routers/api/v1/repo/repo.go b/routers/api/v1/repo/repo.go index 7a3160fa99..35d3490510 100644 --- a/routers/api/v1/repo/repo.go +++ b/routers/api/v1/repo/repo.go @@ -375,8 +375,8 @@ func CreateOrgRepo(ctx *context.APIContext) { return } - if !models.HasOrgVisible(org, ctx.User) { - ctx.NotFound("HasOrgVisible", nil) + if !models.HasOrgOrUserVisible(org, ctx.User) { + ctx.NotFound("HasOrgOrUserVisible", nil) return } diff --git a/routers/api/v1/user/helper.go b/routers/api/v1/user/helper.go index fcdac257ed..a3500e0ee6 100644 --- a/routers/api/v1/user/helper.go +++ b/routers/api/v1/user/helper.go @@ -17,7 +17,7 @@ func GetUserByParamsName(ctx *context.APIContext, name string) *models.User { user, err := models.GetUserByName(username) if err != nil { if models.IsErrUserNotExist(err) { - if redirectUserID, err := models.LookupUserRedirect(username); err == nil { + if redirectUserID, err2 := models.LookupUserRedirect(username); err2 == nil { context.RedirectToUser(ctx.Context, username, redirectUserID) } else { ctx.NotFound("GetUserByName", err) diff --git a/routers/api/v1/user/user.go b/routers/api/v1/user/user.go index 4adae532fd..ac543d597d 100644 --- a/routers/api/v1/user/user.go +++ b/routers/api/v1/user/user.go @@ -57,6 +57,7 @@ func Search(ctx *context.APIContext) { listOptions := utils.GetListOptions(ctx) opts := &models.SearchUserOptions{ + Actor: ctx.User, Keyword: strings.Trim(ctx.Query("q"), " "), UID: ctx.QueryInt64("uid"), Type: models.UserTypeIndividual, @@ -102,10 +103,16 @@ func GetInfo(ctx *context.APIContext) { // "$ref": "#/responses/notFound" u := GetUserByParams(ctx) + if ctx.Written() { return } + if !u.IsVisibleToUser(ctx.User) { + // fake ErrUserNotExist error message to not leak information about existence + ctx.NotFound("GetUserByName", models.ErrUserNotExist{Name: ctx.Params(":username")}) + return + } ctx.JSON(http.StatusOK, convert.ToUser(u, ctx.User)) } diff --git a/routers/web/admin/orgs.go b/routers/web/admin/orgs.go index 618f945704..a2b3ed1bcc 100644 --- a/routers/web/admin/orgs.go +++ b/routers/web/admin/orgs.go @@ -25,7 +25,8 @@ func Organizations(ctx *context.Context) { ctx.Data["PageIsAdminOrganizations"] = true explore.RenderUserSearch(ctx, &models.SearchUserOptions{ - Type: models.UserTypeOrganization, + Actor: ctx.User, + Type: models.UserTypeOrganization, ListOptions: models.ListOptions{ PageSize: setting.UI.Admin.OrgPagingNum, }, diff --git a/routers/web/admin/users.go b/routers/web/admin/users.go index 1b65795865..dc2a97e526 100644 --- a/routers/web/admin/users.go +++ b/routers/web/admin/users.go @@ -37,7 +37,8 @@ func Users(ctx *context.Context) { ctx.Data["PageIsAdminUsers"] = true explore.RenderUserSearch(ctx, &models.SearchUserOptions{ - Type: models.UserTypeIndividual, + Actor: ctx.User, + Type: models.UserTypeIndividual, ListOptions: models.ListOptions{ PageSize: setting.UI.Admin.UserPagingNum, }, @@ -50,6 +51,7 @@ func NewUser(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.users.new_account") ctx.Data["PageIsAdmin"] = true ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DefaultUserVisibilityMode"] = setting.Service.DefaultUserVisibilityMode ctx.Data["login_type"] = "0-0" @@ -70,6 +72,7 @@ func NewUserPost(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("admin.users.new_account") ctx.Data["PageIsAdmin"] = true ctx.Data["PageIsAdminUsers"] = true + ctx.Data["DefaultUserVisibilityMode"] = setting.Service.DefaultUserVisibilityMode sources, err := models.LoginSources() if err != nil { @@ -126,7 +129,8 @@ func NewUserPost(ctx *context.Context) { } u.MustChangePassword = form.MustChangePassword } - if err := models.CreateUser(u); err != nil { + + if err := models.CreateUser(u, &models.CreateUserOverwriteOptions{Visibility: form.Visibility}); err != nil { switch { case models.IsErrUserAlreadyExist(err): ctx.Data["Err_UserName"] = true @@ -312,6 +316,8 @@ func EditUserPost(ctx *context.Context) { u.AllowImportLocal = form.AllowImportLocal u.AllowCreateOrganization = form.AllowCreateOrganization + u.Visibility = form.Visibility + // skip self Prohibit Login if ctx.User.ID == u.ID { u.ProhibitLogin = false diff --git a/routers/web/admin/users_test.go b/routers/web/admin/users_test.go index b19dcb886b..17c5a309b4 100644 --- a/routers/web/admin/users_test.go +++ b/routers/web/admin/users_test.go @@ -8,6 +8,8 @@ import ( "testing" "code.gitea.io/gitea/models" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/web" "code.gitea.io/gitea/services/forms" @@ -121,3 +123,82 @@ func TestNewUserPost_InvalidEmail(t *testing.T) { assert.NotEmpty(t, ctx.Flash.ErrorMsg) } + +func TestNewUserPost_VisiblityDefaultPublic(t *testing.T) { + + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "admin/users/new") + + u := models.AssertExistsAndLoadBean(t, &models.User{ + IsAdmin: true, + ID: 2, + }).(*models.User) + + ctx.User = u + + username := "gitea" + email := "gitea@gitea.io" + + form := forms.AdminCreateUserForm{ + LoginType: "local", + LoginName: "local", + UserName: username, + Email: email, + Password: "abc123ABC!=$", + SendNotify: false, + MustChangePassword: false, + } + + web.SetForm(ctx, &form) + NewUserPost(ctx) + + assert.NotEmpty(t, ctx.Flash.SuccessMsg) + + u, err := models.GetUserByName(username) + + assert.NoError(t, err) + assert.Equal(t, username, u.Name) + assert.Equal(t, email, u.Email) + // As default user visibility + assert.Equal(t, setting.Service.DefaultUserVisibilityMode, u.Visibility) +} + +func TestNewUserPost_VisibilityPrivate(t *testing.T) { + + models.PrepareTestEnv(t) + ctx := test.MockContext(t, "admin/users/new") + + u := models.AssertExistsAndLoadBean(t, &models.User{ + IsAdmin: true, + ID: 2, + }).(*models.User) + + ctx.User = u + + username := "gitea" + email := "gitea@gitea.io" + + form := forms.AdminCreateUserForm{ + LoginType: "local", + LoginName: "local", + UserName: username, + Email: email, + Password: "abc123ABC!=$", + SendNotify: false, + MustChangePassword: false, + Visibility: api.VisibleTypePrivate, + } + + web.SetForm(ctx, &form) + NewUserPost(ctx) + + assert.NotEmpty(t, ctx.Flash.SuccessMsg) + + u, err := models.GetUserByName(username) + + assert.NoError(t, err) + assert.Equal(t, username, u.Name) + assert.Equal(t, email, u.Email) + // As default user visibility + assert.True(t, u.Visibility.IsPrivate()) +} diff --git a/routers/web/org/home.go b/routers/web/org/home.go index ad14f18454..aad0a2a90b 100644 --- a/routers/web/org/home.go +++ b/routers/web/org/home.go @@ -30,8 +30,8 @@ func Home(ctx *context.Context) { org := ctx.Org.Organization - if !models.HasOrgVisible(org, ctx.User) { - ctx.NotFound("HasOrgVisible", nil) + if !models.HasOrgOrUserVisible(org, ctx.User) { + ctx.NotFound("HasOrgOrUserVisible", nil) return } diff --git a/routers/web/user/profile.go b/routers/web/user/profile.go index 72d0066645..631ca21135 100644 --- a/routers/web/user/profile.go +++ b/routers/web/user/profile.go @@ -75,6 +75,17 @@ func Profile(ctx *context.Context) { return } + if ctxUser.IsOrganization() { + org.Home(ctx) + return + } + + // check view permissions + if !ctxUser.IsVisibleToUser(ctx.User) { + ctx.NotFound("user", fmt.Errorf(uname)) + return + } + // Show SSH keys. if isShowKeys { ShowSSHKeys(ctx, ctxUser.ID) @@ -87,11 +98,6 @@ func Profile(ctx *context.Context) { return } - if ctxUser.IsOrganization() { - org.Home(ctx) - return - } - // Show OpenID URIs openIDs, err := models.GetUserOpenIDs(ctxUser.ID) if err != nil { diff --git a/routers/web/user/setting/profile.go b/routers/web/user/setting/profile.go index 20042caca4..463c4ec203 100644 --- a/routers/web/user/setting/profile.go +++ b/routers/web/user/setting/profile.go @@ -114,6 +114,7 @@ func ProfilePost(ctx *context.Context) { } ctx.User.Description = form.Description ctx.User.KeepActivityPrivate = form.KeepActivityPrivate + ctx.User.Visibility = form.Visibility if err := models.UpdateUserSetting(ctx.User); err != nil { if _, ok := err.(models.ErrEmailAlreadyUsed); ok { ctx.Flash.Error(ctx.Tr("form.email_been_used")) diff --git a/services/forms/admin.go b/services/forms/admin.go index 2e6bbaf172..5abef0550e 100644 --- a/services/forms/admin.go +++ b/services/forms/admin.go @@ -8,6 +8,7 @@ import ( "net/http" "code.gitea.io/gitea/modules/context" + "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web/middleware" "gitea.com/go-chi/binding" @@ -22,6 +23,7 @@ type AdminCreateUserForm struct { Password string `binding:"MaxSize(255)"` SendNotify bool MustChangePassword bool + Visibility structs.VisibleType } // Validate validates form fields @@ -49,6 +51,7 @@ type AdminEditUserForm struct { AllowCreateOrganization bool ProhibitLogin bool Reset2FA bool `form:"reset_2fa"` + Visibility structs.VisibleType } // Validate validates form fields diff --git a/services/forms/user_form.go b/services/forms/user_form.go index 903a625da0..439ddfc7c6 100644 --- a/services/forms/user_form.go +++ b/services/forms/user_form.go @@ -12,6 +12,7 @@ import ( "code.gitea.io/gitea/modules/context" "code.gitea.io/gitea/modules/setting" + "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/modules/web/middleware" "gitea.com/go-chi/binding" @@ -230,6 +231,7 @@ type UpdateProfileForm struct { Location string `binding:"MaxSize(50)"` Language string Description string `binding:"MaxSize(255)"` + Visibility structs.VisibleType KeepActivityPrivate bool } diff --git a/templates/admin/user/edit.tmpl b/templates/admin/user/edit.tmpl index af01489c0a..dba24d9837 100644 --- a/templates/admin/user/edit.tmpl +++ b/templates/admin/user/edit.tmpl @@ -28,6 +28,33 @@ </div> </div> </div> + + <div class="inline field {{if .Err_Visibility}}error{{end}}"> + <span class="inline required field"><label for="visibility">{{.i18n.Tr "settings.visibility"}}</label></span> + <div class="ui selection type dropdown"> + {{if .User.Visibility.IsPublic}} + <input type="hidden" id="visibility" name="visibility" value="0"> + {{end}} + {{if .User.Visibility.IsLimited}} + <input type="hidden" id="visibility" name="visibility" value="1"> + {{end}} + {{if .User.Visibility.IsPrivate}} + <input type="hidden" id="visibility" name="visibility" value="2"> + {{end}} + <div class="text"> + {{if .User.Visibility.IsPublic}}{{.i18n.Tr "settings.visibility.public"}}{{end}} + {{if .User.Visibility.IsLimited}}{{.i18n.Tr "settings.visibility.limited"}}{{end}} + {{if .User.Visibility.IsPrivate}}{{.i18n.Tr "settings.visibility.private"}}{{end}} + </div> + {{svg "octicon-triangle-down" 14 "dropdown icon"}} + <div class="menu"> + <div class="item poping up" data-content="{{.i18n.Tr "settings.visibility.public_tooltip"}}" data-value="0">{{.i18n.Tr "settings.visibility.public"}}</div> + <div class="item poping up" data-content="{{.i18n.Tr "settings.visibility.limited_tooltip"}}" data-value="1">{{.i18n.Tr "settings.visibility.limited"}}</div> + <div class="item poping up" data-content="{{.i18n.Tr "settings.visibility.private_tooltip"}}" data-value="2">{{.i18n.Tr "settings.visibility.private"}}</div> + </div> + </div> + </div> + <div class="required non-local field {{if .Err_LoginName}}error{{end}} {{if eq .User.LoginSource 0}}hide{{end}}"> <label for="login_name">{{.i18n.Tr "admin.users.auth_login_name"}}</label> <input id="login_name" name="login_name" value="{{.User.LoginName}}" autofocus> diff --git a/templates/admin/user/new.tmpl b/templates/admin/user/new.tmpl index 885045dd02..2e39172535 100644 --- a/templates/admin/user/new.tmpl +++ b/templates/admin/user/new.tmpl @@ -24,6 +24,25 @@ </div> </div> </div> + + <div class="inline field {{if .Err_Visibility}}error{{end}}"> + <span class="inline required field"><label for="visibility">{{.i18n.Tr "settings.visibility"}}</label></span> + <div class="ui selection type dropdown"> + <input type="hidden" id="visibility" name="visibility" value="{{.visibility}}"> + <div class="text"> + {{if .DefaultUserVisibilityMode.IsPublic}}{{.i18n.Tr "settings.visibility.public"}}{{end}} + {{if .DefaultUserVisibilityMode.IsLimited}}{{.i18n.Tr "settings.visibility.limited"}}{{end}} + {{if .DefaultUserVisibilityMode.IsPrivate}}{{.i18n.Tr "settings.visibility.private"}}{{end}} + </div> + {{svg "octicon-triangle-down" 14 "dropdown icon"}} + <div class="menu"> + <div class="item poping up" data-content="{{.i18n.Tr "settings.visibility.public_tooltip"}}" data-value="0">{{.i18n.Tr "settings.visibility.public"}}</div> + <div class="item poping up" data-content="{{.i18n.Tr "settings.visibility.limited_tooltip"}}" data-value="1">{{.i18n.Tr "settings.visibility.limited"}}</div> + <div class="item poping up" data-content="{{.i18n.Tr "settings.visibility.private_tooltip"}}" data-value="2">{{.i18n.Tr "settings.visibility.private"}}</div> + </div> + </div> + </div> + <div class="required non-local field {{if .Err_LoginName}}error{{end}} {{if eq .login_type "0-0"}}hide{{end}}"> <label for="login_name">{{.i18n.Tr "admin.users.auth_login_name"}}</label> <input id="login_name" name="login_name" value="{{.login_name}}"> diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 9453b1af32..7f7907b3b0 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -13334,6 +13334,10 @@ "username": { "type": "string", "x-go-name": "Username" + }, + "visibility": { + "type": "string", + "x-go-name": "Visibility" } }, "x-go-package": "code.gitea.io/gitea/modules/structs" @@ -14143,6 +14147,10 @@ "format": "int64", "x-go-name": "SourceID" }, + "visibility": { + "type": "string", + "x-go-name": "Visibility" + }, "website": { "type": "string", "x-go-name": "Website" @@ -16637,6 +16645,11 @@ "format": "int64", "x-go-name": "StarredRepos" }, + "visibility": { + "description": "User visibility level option: public, limited, private", + "type": "string", + "x-go-name": "Visibility" + }, "website": { "description": "the user's website", "type": "string", diff --git a/templates/user/settings/profile.tmpl b/templates/user/settings/profile.tmpl index 9f07226632..4b860049d8 100644 --- a/templates/user/settings/profile.tmpl +++ b/templates/user/settings/profile.tmpl @@ -47,27 +47,62 @@ <input id="location" name="location" value="{{.SignedUser.Location}}"> </div> - <div class="field"> - <label for="language">{{.i18n.Tr "settings.language"}}</label> - <div class="ui language selection dropdown" id="language"> - <input name="language" type="hidden" value="{{.SignedUser.Language}}"> - {{svg "octicon-triangle-down" 14 "dropdown icon"}} - <div class="text">{{range .AllLangs}}{{if eq $.SignedUser.Language .Lang}}{{.Name}}{{end}}{{end}}</div> - <div class="menu"> - {{range .AllLangs}} - <div class="item{{if eq $.SignedUser.Language .Lang}} active selected{{end}}" data-value="{{.Lang}}">{{.Name}}</div> - {{end}} - </div> + <div class="field"> + <label for="language">{{.i18n.Tr "settings.language"}}</label> + <div class="ui language selection dropdown" id="language"> + <input name="language" type="hidden" value="{{.SignedUser.Language}}"> + {{svg "octicon-triangle-down" 14 "dropdown icon"}} + <div class="text">{{range .AllLangs}}{{if eq $.SignedUser.Language .Lang}}{{.Name}}{{end}}{{end}}</div> + <div class="menu"> + {{range .AllLangs}} + <div class="item{{if eq $.SignedUser.Language .Lang}} active selected{{end}}" data-value="{{.Lang}}">{{.Name}}</div> + {{end}} </div> </div> + </div> + + <div class="ui divider"></div> + <!-- private block --> + + <div class="field"> + <label for="security-private"><strong>{{.i18n.Tr "settings.privacy"}}</strong></label> + </div> + + <div class="inline field {{if .Err_Visibility}}error{{end}}"> + <span class="inline required field"><label for="visibility">{{.i18n.Tr "settings.visibility"}}</label></span> + <div class="ui selection type dropdown"> + {{if .SignedUser.Visibility.IsPublic}} + <input type="hidden" id="visibility" name="visibility" value="0"> + {{end}} + {{if .SignedUser.Visibility.IsLimited}} + <input type="hidden" id="visibility" name="visibility" value="1"> + {{end}} + {{if .SignedUser.Visibility.IsPrivate}} + <input type="hidden" id="visibility" name="visibility" value="2"> + {{end}} + <div class="text"> + {{if .SignedUser.Visibility.IsPublic}}{{.i18n.Tr "settings.visibility.public"}}{{end}} + {{if .SignedUser.Visibility.IsLimited}}{{.i18n.Tr "settings.visibility.limited"}}{{end}} + {{if .SignedUser.Visibility.IsPrivate}}{{.i18n.Tr "settings.visibility.private"}}{{end}} + </div> + {{svg "octicon-triangle-down" 14 "dropdown icon"}} + <div class="menu"> + <div class="item poping up" data-content="{{.i18n.Tr "settings.visibility.public_tooltip"}}" data-value="0">{{.i18n.Tr "settings.visibility.public"}}</div> + <div class="item poping up" data-content="{{.i18n.Tr "settings.visibility.limited_tooltip"}}" data-value="1">{{.i18n.Tr "settings.visibility.limited"}}</div> + <div class="item poping up" data-content="{{.i18n.Tr "settings.visibility.private_tooltip"}}" data-value="2">{{.i18n.Tr "settings.visibility.private"}}</div> + </div> + </div> + </div> <div class="field"> - <label for="keep-activity-private">{{.i18n.Tr "settings.privacy"}}</label> <div class="ui checkbox" id="keep-activity-private"> <label class="poping up" data-content="{{.i18n.Tr "settings.keep_activity_private_popup"}}"><strong>{{.i18n.Tr "settings.keep_activity_private"}}</strong></label> <input name="keep_activity_private" type="checkbox" {{if .SignedUser.KeepActivityPrivate}}checked{{end}}> </div> </div> + + <div class="ui divider"></div> + <div class="field"> <button class="ui green button">{{$.i18n.Tr "settings.update_profile"}}</button> </div> |