Browse Source

#1692 APIs: Users Followers

- User profile un/follow
- List user's followers/following
Unknwon 7 years ago
parent
commit
a49af93faf

+ 1 - 1
.bra.toml

@@ -13,7 +13,7 @@ watch_dirs = [
 watch_exts = [".go"]
 build_delay = 1500
 cmds = [
-	["go", "install", "-race"], # sqlite redis memcache cert pam tidb
+	["go", "install", "-v", "-race"], # sqlite redis memcache cert pam tidb
 	["go", "build", "-race"],
 	["./gogs", "web"]
 ]

+ 1 - 1
Makefile

@@ -16,7 +16,7 @@ NOW = $(shell date -u '+%Y%m%d%I%M%S')
 .IGNORE: public/css/gogs.css
 
 build: $(GENERATED)
-	go install -ldflags '$(LDFLAGS)' -tags '$(TAGS)'
+	go install -v -ldflags '$(LDFLAGS)' -tags '$(TAGS)'
 	cp '$(GOPATH)/bin/gogs' .
 
 govet:

+ 3 - 1
README.md

@@ -3,7 +3,7 @@ Gogs - Go Git Service [![Build Status](https://travis-ci.org/gogits/gogs.svg?bra
 
 ![](https://github.com/gogits/gogs/blob/master/public/img/gogs-large-resize.png?raw=true)
 
-##### Current version: 0.8.12
+##### Current version: 0.8.13
 
 | Web | UI  | Preview  |
 |:-------------:|:-------:|:-------:|
@@ -82,6 +82,7 @@ There are 5 ways to install Gogs:
 - [阿里云上 Ubuntu 14.04 64 位安装 Gogs](http://my.oschina.net/luyao/blog/375654) (Chinese)
 - [Installing Gogs on FreeBSD](https://www.codejam.info/2015/03/installing-gogs-on-freebsd.html)
 - [Gogs on Raspberry Pi](http://blog.meinside.pe.kr/Gogs-on-Raspberry-Pi/)
+- [Cloudflare Full SSL with GOGS (Go Git Service) using NGINX](http://www.listekconsulting.com/articles/cloudflare-full-ssl-with-gogs-go-git-service-using-nginx/)
 
 ### Screencasts
 
@@ -101,6 +102,7 @@ There are 5 ways to install Gogs:
 - [Drone](https://github.com/drone/drone) (CI)
 - [Fabric8](http://fabric8.io/) (DevOps)
 - [Taiga](https://taiga.io/) (Project Management)
+- [Puppet](https://forge.puppetlabs.com/Siteminds/gogs) (IT)
 
 ### Product Support
 

+ 1 - 0
README_ZH.md

@@ -73,6 +73,7 @@ Gogs 的目标是打造一个最简单、最快速和最轻松的方式搭建自
 - [Drone](https://github.com/drone/drone)(CI)
 - [Fabric8](http://fabric8.io/)(DevOps)
 - [Taiga](https://taiga.io/)(项目管理)
+- [Puppet](https://forge.puppetlabs.com/Siteminds/gogs)(IT)
 
 ### 产品支持
 

+ 11 - 1
cmd/web.go

@@ -289,7 +289,13 @@ func runWeb(ctx *cli.Context) {
 	// ***** END: Admin *****
 
 	m.Group("", func() {
-		m.Get("/:username", user.Profile)
+		m.Group("/:username", func() {
+			m.Get("", user.Profile)
+			m.Get("/followers", user.Followers)
+			m.Get("/following", user.Following)
+			m.Get("/stars", user.Stars)
+		})
+
 		m.Get("/attachments/:uuid", func(ctx *middleware.Context) {
 			attach, err := models.GetAttachmentByUUID(ctx.Params(":uuid"))
 			if err != nil {
@@ -319,6 +325,10 @@ func runWeb(ctx *cli.Context) {
 		m.Post("/issues/attachments", repo.UploadIssueAttachment)
 	}, ignSignIn)
 
+	m.Group("/:username", func() {
+		m.Get("/action/:action", user.Action)
+	}, reqSignIn)
+
 	if macaron.Env == macaron.DEV {
 		m.Get("/template/*", dev.TemplatePreview)
 	}

+ 3 - 1
conf/locale/locale_en-US.ini

@@ -230,8 +230,10 @@ join_on = Joined on
 repositories = Repositories
 activity = Public Activity
 followers = Followers
-starred = Starred
+starred = Starred repositories
 following = Following
+follow = Follow
+unfollow = Unfollow
 
 form.name_reserved = Username '%s' is reserved.
 form.name_pattern_not_allowed = Username pattern '%s' is not allowed.

+ 1 - 1
gogs.go

@@ -17,7 +17,7 @@ import (
 	"github.com/gogits/gogs/modules/setting"
 )
 
-const APP_VER = "0.8.12.1219"
+const APP_VER = "0.8.13.1221"
 
 func init() {
 	runtime.GOMAXPROCS(runtime.NumCPU())

+ 41 - 0
models/issue.go

@@ -665,6 +665,47 @@ func GetIssueUserPairsByMode(uid, rid int64, isClosed bool, page, filterMode int
 	return ius, err
 }
 
+func UpdateMentions(userNames []string, issueId int64) error {
+	for i := range userNames {
+		userNames[i] = strings.ToLower(userNames[i])
+	}
+	users := make([]*User, 0, len(userNames))
+
+	if err := x.Where("lower_name IN (?)", strings.Join(userNames, "\",\"")).OrderBy("lower_name ASC").Find(&users); err != nil {
+		return err
+	}
+
+	ids := make([]int64, 0, len(userNames))
+	for _, user := range users {
+		ids = append(ids, user.Id)
+		if !user.IsOrganization() {
+			continue
+		}
+
+		if user.NumMembers == 0 {
+			continue
+		}
+
+		tempIds := make([]int64, 0, user.NumMembers)
+		orgUsers, err := GetOrgUsersByOrgId(user.Id)
+		if err != nil {
+			return err
+		}
+
+		for _, orgUser := range orgUsers {
+			tempIds = append(tempIds, orgUser.ID)
+		}
+
+		ids = append(ids, tempIds...)
+	}
+
+	if err := UpdateIssueUsersByMentions(ids, issueId); err != nil {
+		return err
+	}
+
+	return nil
+}
+
 // IssueStats represents issue statistic information.
 type IssueStats struct {
 	OpenCount, ClosedCount int64

+ 73 - 72
models/user.go

@@ -56,7 +56,7 @@ type User struct {
 	LowerName string `xorm:"UNIQUE NOT NULL"`
 	Name      string `xorm:"UNIQUE NOT NULL"`
 	FullName  string
-	// Email is the primary email address (to be used for communication).
+	// Email is the primary email address (to be used for communication)
 	Email       string `xorm:"NOT NULL"`
 	Passwd      string `xorm:"NOT NULL"`
 	LoginType   LoginType
@@ -78,24 +78,24 @@ type User struct {
 	// Maximum repository creation limit, -1 means use gloabl default
 	MaxRepoCreation int `xorm:"NOT NULL DEFAULT -1"`
 
-	// Permissions.
+	// Permissions
 	IsActive         bool
 	IsAdmin          bool
 	AllowGitHook     bool
 	AllowImportLocal bool // Allow migrate repository by local path
 
-	// Avatar.
+	// Avatar
 	Avatar          string `xorm:"VARCHAR(2048) NOT NULL"`
 	AvatarEmail     string `xorm:"NOT NULL"`
 	UseCustomAvatar bool
 
-	// Counters.
-	NumFollowers  int
-	NumFollowings int
-	NumStars      int
-	NumRepos      int
+	// Counters
+	NumFollowers int
+	NumFollowing int `xorm:"NOT NULL"`
+	NumStars     int
+	NumRepos     int
 
-	// For organization.
+	// For organization
 	Description string
 	NumTeams    int
 	NumMembers  int
@@ -263,6 +263,34 @@ func (u *User) AvatarLink() string {
 	return link
 }
 
+// User.GetFollwoers returns range of user's followers.
+func (u *User) GetFollowers(page int) ([]*User, error) {
+	users := make([]*User, 0, ItemsPerPage)
+	sess := x.Limit(ItemsPerPage, (page-1)*ItemsPerPage).Where("follow.follow_id=?", u.Id)
+	if setting.UsePostgreSQL {
+		sess = sess.Join("LEFT", "follow", `"user".id=follow.user_id`)
+	} else {
+		sess = sess.Join("LEFT", "follow", "user.id=follow.user_id")
+	}
+	return users, sess.Find(&users)
+}
+
+func (u *User) IsFollowing(followID int64) bool {
+	return IsFollowing(u.Id, followID)
+}
+
+// GetFollowing returns range of user's following.
+func (u *User) GetFollowing(page int) ([]*User, error) {
+	users := make([]*User, 0, ItemsPerPage)
+	sess := x.Limit(ItemsPerPage, (page-1)*ItemsPerPage).Where("follow.user_id=?", u.Id)
+	if setting.UsePostgreSQL {
+		sess = sess.Join("LEFT", "follow", `"user".id=follow.follow_id`)
+	} else {
+		sess = sess.Join("LEFT", "follow", "user.id=follow.follow_id")
+	}
+	return users, sess.Find(&users)
+}
+
 // NewGitSig generates and returns the signature of given user.
 func (u *User) NewGitSig() *git.Signature {
 	return &git.Signature{
@@ -1077,100 +1105,73 @@ func SearchUserByName(opt SearchOption) (us []*User, err error) {
 	return us, err
 }
 
-// Follow is connection request for receiving user notification.
+// ___________    .__  .__
+// \_   _____/___ |  | |  |   ______  _  __
+//  |    __)/  _ \|  | |  |  /  _ \ \/ \/ /
+//  |     \(  <_> )  |_|  |_(  <_> )     /
+//  \___  / \____/|____/____/\____/ \/\_/
+//      \/
+
+// Follow represents relations of user and his/her followers.
 type Follow struct {
 	ID       int64 `xorm:"pk autoincr"`
 	UserID   int64 `xorm:"UNIQUE(follow)"`
 	FollowID int64 `xorm:"UNIQUE(follow)"`
 }
 
+func IsFollowing(userID, followID int64) bool {
+	has, _ := x.Get(&Follow{UserID: userID, FollowID: followID})
+	return has
+}
+
 // FollowUser marks someone be another's follower.
-func FollowUser(userId int64, followId int64) (err error) {
+func FollowUser(userID, followID int64) (err error) {
+	if userID == followID || IsFollowing(userID, followID) {
+		return nil
+	}
+
 	sess := x.NewSession()
-	defer sess.Close()
-	sess.Begin()
+	defer sessionRelease(sess)
+	if err = sess.Begin(); err != nil {
+		return err
+	}
 
-	if _, err = sess.Insert(&Follow{UserID: userId, FollowID: followId}); err != nil {
-		sess.Rollback()
+	if _, err = sess.Insert(&Follow{UserID: userID, FollowID: followID}); err != nil {
 		return err
 	}
 
-	rawSql := "UPDATE `user` SET num_followers = num_followers + 1 WHERE id = ?"
-	if _, err = sess.Exec(rawSql, followId); err != nil {
-		sess.Rollback()
+	if _, err = sess.Exec("UPDATE `user` SET num_followers = num_followers + 1 WHERE id = ?", followID); err != nil {
 		return err
 	}
 
-	rawSql = "UPDATE `user` SET num_followings = num_followings + 1 WHERE id = ?"
-	if _, err = sess.Exec(rawSql, userId); err != nil {
-		sess.Rollback()
+	if _, err = sess.Exec("UPDATE `user` SET num_following = num_following + 1 WHERE id = ?", userID); err != nil {
 		return err
 	}
 	return sess.Commit()
 }
 
-// UnFollowUser unmarks someone be another's follower.
-func UnFollowUser(userId int64, unFollowId int64) (err error) {
-	session := x.NewSession()
-	defer session.Close()
-	session.Begin()
-
-	if _, err = session.Delete(&Follow{UserID: userId, FollowID: unFollowId}); err != nil {
-		session.Rollback()
-		return err
+// UnfollowUser unmarks someone be another's follower.
+func UnfollowUser(userID, followID int64) (err error) {
+	if userID == followID || !IsFollowing(userID, followID) {
+		return nil
 	}
 
-	rawSql := "UPDATE `user` SET num_followers = num_followers - 1 WHERE id = ?"
-	if _, err = session.Exec(rawSql, unFollowId); err != nil {
-		session.Rollback()
+	sess := x.NewSession()
+	defer sessionRelease(sess)
+	if err = sess.Begin(); err != nil {
 		return err
 	}
 
-	rawSql = "UPDATE `user` SET num_followings = num_followings - 1 WHERE id = ?"
-	if _, err = session.Exec(rawSql, userId); err != nil {
-		session.Rollback()
+	if _, err = sess.Delete(&Follow{UserID: userID, FollowID: followID}); err != nil {
 		return err
 	}
-	return session.Commit()
-}
 
-func UpdateMentions(userNames []string, issueId int64) error {
-	for i := range userNames {
-		userNames[i] = strings.ToLower(userNames[i])
-	}
-	users := make([]*User, 0, len(userNames))
-
-	if err := x.Where("lower_name IN (?)", strings.Join(userNames, "\",\"")).OrderBy("lower_name ASC").Find(&users); err != nil {
+	if _, err = sess.Exec("UPDATE `user` SET num_followers = num_followers - 1 WHERE id = ?", followID); err != nil {
 		return err
 	}
 
-	ids := make([]int64, 0, len(userNames))
-	for _, user := range users {
-		ids = append(ids, user.Id)
-		if !user.IsOrganization() {
-			continue
-		}
-
-		if user.NumMembers == 0 {
-			continue
-		}
-
-		tempIds := make([]int64, 0, user.NumMembers)
-		orgUsers, err := GetOrgUsersByOrgId(user.Id)
-		if err != nil {
-			return err
-		}
-
-		for _, orgUser := range orgUsers {
-			tempIds = append(tempIds, orgUser.ID)
-		}
-
-		ids = append(ids, tempIds...)
-	}
-
-	if err := UpdateIssueUsersByMentions(ids, issueId); err != nil {
+	if _, err = sess.Exec("UPDATE `user` SET num_following = num_following - 1 WHERE id = ?", userID); err != nil {
 		return err
 	}
-
-	return nil
+	return sess.Commit()
 }

File diff suppressed because it is too large
+ 2 - 2
modules/bindata/bindata.go


+ 2 - 1
modules/middleware/context.go

@@ -242,7 +242,8 @@ func Contexter() macaron.Handler {
 
 		ctx.Data["CsrfToken"] = x.GetToken()
 		ctx.Data["CsrfTokenHtml"] = template.HTML(`<input type="hidden" name="_csrf" value="` + x.GetToken() + `">`)
-		log.Debug("CSRF Token: %v | %v", ctx.Data["CsrfToken"], ctx.GetCookie("_csrf"))
+		log.Debug("Session ID: %s", sess.ID())
+		log.Debug("CSRF Token: %v", ctx.Data["CsrfToken"])
 
 		ctx.Data["ShowRegistrationButton"] = setting.Service.ShowRegistrationButton
 		ctx.Data["ShowFooterBranding"] = setting.ShowFooterBranding

+ 41 - 26
public/css/gogs.css

@@ -2465,31 +2465,6 @@ footer .container .links > *:first-child {
 .repository.new.release .prerelease.field {
   margin-bottom: 0;
 }
-.repository.watchers .list {
-  padding: 0;
-}
-.repository.watchers .list .item {
-  list-style: none;
-  width: 32%;
-  margin: 10px 10px 10px 0;
-  padding-bottom: 14px;
-  float: left;
-}
-.repository.watchers .list .item .avatar {
-  width: 48px;
-  height: 48px;
-  float: left;
-  display: block;
-  margin-right: 10px;
-}
-.repository.watchers .list .item .name {
-  margin-top: 0;
-  margin-bottom: 0;
-  font-weight: normal;
-}
-.repository.watchers .list .item .meta {
-  margin-top: 5px;
-}
 .repository.forks .list {
   margin-top: 0;
 }
@@ -2551,6 +2526,31 @@ footer .container .links > *:first-child {
   margin-left: 5px;
   margin-top: -3px;
 }
+.user-cards .list {
+  padding: 0;
+}
+.user-cards .list .item {
+  list-style: none;
+  width: 32%;
+  margin: 10px 10px 10px 0;
+  padding-bottom: 14px;
+  float: left;
+}
+.user-cards .list .item .avatar {
+  width: 48px;
+  height: 48px;
+  float: left;
+  display: block;
+  margin-right: 10px;
+}
+.user-cards .list .item .name {
+  margin-top: 0;
+  margin-bottom: 0;
+  font-weight: normal;
+}
+.user-cards .list .item .meta {
+  margin-top: 5px;
+}
 #search-repo-box .results,
 #search-user-box .results {
   padding: 0;
@@ -2862,7 +2862,7 @@ footer .container .links > *:first-child {
   margin-left: 5px;
   margin-top: -3px;
 }
-.user {
+.user:not(.icon) {
   padding-top: 15px;
   padding-bottom: 80px;
 }
@@ -2893,9 +2893,24 @@ footer .container .links > *:first-child {
 .user.profile .ui.card .extra.content ul li:not(:last-child) {
   border-bottom: 1px solid #eaeaea;
 }
+.user.profile .ui.card .extra.content ul li .octicon {
+  margin-left: 1px;
+  margin-right: 5px;
+}
+.user.profile .ui.card .extra.content ul li.follow .ui.button {
+  width: 100%;
+}
 .user.profile .ui.repository.list {
   margin-top: 25px;
 }
+.user.followers .header.name {
+  font-size: 20px;
+  line-height: 24px;
+  vertical-align: middle;
+}
+.user.followers .follow .ui.button {
+  padding: 8px 15px;
+}
 .dashboard {
   padding-top: 15px;
   padding-bottom: 80px;

+ 30 - 29
public/less/_repository.less

@@ -859,35 +859,6 @@
 		}
 	}
 
-	&.watchers {
-		.list {
-			padding: 0;
-
-			.item {
-				list-style: none;
-				width: 32%;
-				margin: 10px 10px 10px 0;
-				padding-bottom: 14px;
-				float: left;
-
-				.avatar {
-					width: 48px;
-					height: 48px;
-					float: left;
-					display: block;
-					margin-right: 10px;
-				}
-				.name {
-					margin-top: 0;
-					margin-bottom: 0;
-					font-weight: normal;
-				}
-				.meta {
-					margin-top: 5px;
-				}
-			}
-		}
-	}
 	&.forks {
 		.list {
 			margin-top: 0;
@@ -982,6 +953,36 @@
 }
 // End of .repository
 
+&.user-cards {
+	.list {
+		padding: 0;
+
+		.item {
+			list-style: none;
+			width: 32%;
+			margin: 10px 10px 10px 0;
+			padding-bottom: 14px;
+			float: left;
+
+			.avatar {
+				width: 48px;
+				height: 48px;
+				float: left;
+				display: block;
+				margin-right: 10px;
+			}
+			.name {
+				margin-top: 0;
+				margin-bottom: 0;
+				font-weight: normal;
+			}
+			.meta {
+				margin-top: 5px;
+			}
+		}
+	}
+}
+
 #search-repo-box,
 #search-user-box {
 	.results {

+ 29 - 2
public/less/_user.less

@@ -1,6 +1,8 @@
 .user {
-	padding-top: 15px;
-	padding-bottom: @footer-margin * 2;
+	&:not(.icon) {
+		padding-top: 15px;
+		padding-bottom: @footer-margin * 2;
+	}
 
 	&.settings {
 		.list {
@@ -38,6 +40,17 @@
 						&:not(:last-child) {
 							border-bottom: 1px solid #eaeaea;
 						}
+
+						.octicon {
+							margin-left: 1px;
+							margin-right: 5px;
+						}
+
+						&.follow {
+							.ui.button {
+								width: 100%;
+							}
+						}
 					}
 				}
 			}
@@ -47,4 +60,18 @@
 			margin-top: 25px;
 		}
 	}
+
+	&.followers {
+		.header.name {
+			font-size: 20px;
+			line-height: 24px;
+			vertical-align: middle;
+		}
+
+		.follow {
+			.ui.button {
+				padding: 8px 15px;
+			}
+		}
+	}
 }

+ 16 - 3
routers/api/v1/api.go

@@ -135,19 +135,32 @@ func RegisterRoutes(m *macaron.Macaron) {
 		m.Group("/users", func() {
 			m.Group("/:username", func() {
 				m.Get("/keys", user.ListPublicKeys)
+
+				m.Get("/followers", user.ListFollowers)
+				m.Group("/following", func() {
+					m.Get("", user.ListFollowing)
+					m.Get("/:target", user.CheckFollowing)
+				})
 			})
 		}, ReqToken())
 
 		m.Group("/user", func() {
+			m.Combo("/emails").Get(user.ListEmails).
+				Post(bind(api.CreateEmailOption{}), user.AddEmail).
+				Delete(bind(api.CreateEmailOption{}), user.DeleteEmail)
+
+			m.Get("/followers", user.ListMyFollowers)
+			m.Group("/following", func() {
+				m.Get("", user.ListMyFollowing)
+				m.Combo("/:username").Get(user.CheckMyFollowing).Put(user.Follow).Delete(user.Unfollow)
+			})
+
 			m.Group("/keys", func() {
 				m.Combo("").Get(user.ListMyPublicKeys).
 					Post(bind(api.CreateKeyOption{}), user.CreatePublicKey)
 				m.Combo("/:id").Get(user.GetPublicKey).
 					Delete(user.DeletePublicKey)
 			})
-			m.Combo("/emails").Get(user.ListEmails).
-				Post(bind(api.CreateEmailOption{}), user.AddEmail).
-				Delete(bind(api.CreateEmailOption{}), user.DeleteEmail)
 		}, ReqToken())
 
 		// Repositories

+ 121 - 0
routers/api/v1/user/followers.go

@@ -0,0 +1,121 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package user
+
+import (
+	api "github.com/gogits/go-gogs-client"
+
+	"github.com/gogits/gogs/models"
+	"github.com/gogits/gogs/modules/middleware"
+	"github.com/gogits/gogs/routers/api/v1/convert"
+)
+
+func responseApiUsers(ctx *middleware.Context, users []*models.User) {
+	apiUsers := make([]*api.User, len(users))
+	for i := range users {
+		apiUsers[i] = convert.ToApiUser(users[i])
+	}
+	ctx.JSON(200, &apiUsers)
+}
+
+func listUserFollowers(ctx *middleware.Context, u *models.User) {
+	users, err := u.GetFollowers(ctx.QueryInt("page"))
+	if err != nil {
+		ctx.APIError(500, "GetUserFollowers", err)
+		return
+	}
+	responseApiUsers(ctx, users)
+}
+
+func ListMyFollowers(ctx *middleware.Context) {
+	listUserFollowers(ctx, ctx.User)
+}
+
+// https://github.com/gogits/go-gogs-client/wiki/Users-Followers#list-followers-of-a-user
+func ListFollowers(ctx *middleware.Context) {
+	u := GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+	listUserFollowers(ctx, u)
+}
+
+func listUserFollowing(ctx *middleware.Context, u *models.User) {
+	users, err := u.GetFollowing(ctx.QueryInt("page"))
+	if err != nil {
+		ctx.APIError(500, "GetFollowing", err)
+		return
+	}
+	responseApiUsers(ctx, users)
+}
+
+func ListMyFollowing(ctx *middleware.Context) {
+	listUserFollowing(ctx, ctx.User)
+}
+
+// https://github.com/gogits/go-gogs-client/wiki/Users-Followers#list-users-followed-by-another-user
+func ListFollowing(ctx *middleware.Context) {
+	u := GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+	listUserFollowing(ctx, u)
+}
+
+func checkUserFollowing(ctx *middleware.Context, u *models.User, followID int64) {
+	if u.IsFollowing(followID) {
+		ctx.Status(204)
+	} else {
+		ctx.Error(404)
+	}
+}
+
+// https://github.com/gogits/go-gogs-client/wiki/Users-Followers#check-if-you-are-following-a-user
+func CheckMyFollowing(ctx *middleware.Context) {
+	target := GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+	checkUserFollowing(ctx, ctx.User, target.Id)
+}
+
+// https://github.com/gogits/go-gogs-client/wiki/Users-Followers#check-if-one-user-follows-another
+func CheckFollowing(ctx *middleware.Context) {
+	u := GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+	target := GetUserByParamsName(ctx, ":target")
+	if ctx.Written() {
+		return
+	}
+	checkUserFollowing(ctx, u, target.Id)
+}
+
+// https://github.com/gogits/go-gogs-client/wiki/Users-Followers#follow-a-user
+func Follow(ctx *middleware.Context) {
+	target := GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+	if err := models.FollowUser(ctx.User.Id, target.Id); err != nil {
+		ctx.APIError(500, "FollowUser", err)
+		return
+	}
+	ctx.Status(204)
+}
+
+// https://github.com/gogits/go-gogs-client/wiki/Users-Followers#unfollow-a-user
+func Unfollow(ctx *middleware.Context) {
+	target := GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+	if err := models.UnfollowUser(ctx.User.Id, target.Id); err != nil {
+		ctx.APIError(500, "UnfollowUser", err)
+		return
+	}
+	ctx.Status(204)
+}

+ 1 - 5
routers/repo/repo.go

@@ -244,11 +244,7 @@ func Action(ctx *middleware.Context) {
 	}
 
 	if err != nil {
-		log.Error(4, "Action(%s): %v", ctx.Params(":action"), err)
-		ctx.JSON(200, map[string]interface{}{
-			"ok":  false,
-			"err": err.Error(),
-		})
+		ctx.Handle(500, fmt.Sprintf("Action (%s)", ctx.Params(":action")), err)
 		return
 	}
 

+ 7 - 5
routers/repo/view.go

@@ -217,7 +217,7 @@ func Home(ctx *middleware.Context) {
 	ctx.HTML(200, HOME)
 }
 
-func renderItems(ctx *middleware.Context, total int, getter func(page int) ([]*models.User, error)) {
+func RenderUserCards(ctx *middleware.Context, total int, getter func(page int) ([]*models.User, error), tpl base.TplName) {
 	page := ctx.QueryInt("page")
 	if page <= 0 {
 		page = 1
@@ -230,21 +230,23 @@ func renderItems(ctx *middleware.Context, total int, getter func(page int) ([]*m
 		ctx.Handle(500, "getter", err)
 		return
 	}
-	ctx.Data["Watchers"] = items
+	ctx.Data["Cards"] = items
 
-	ctx.HTML(200, WATCHERS)
+	ctx.HTML(200, tpl)
 }
 
 func Watchers(ctx *middleware.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.watchers")
+	ctx.Data["CardsTitle"] = ctx.Tr("repo.watchers")
 	ctx.Data["PageIsWatchers"] = true
-	renderItems(ctx, ctx.Repo.Repository.NumWatches, ctx.Repo.Repository.GetWatchers)
+	RenderUserCards(ctx, ctx.Repo.Repository.NumWatches, ctx.Repo.Repository.GetWatchers, WATCHERS)
 }
 
 func Stars(ctx *middleware.Context) {
 	ctx.Data["Title"] = ctx.Tr("repo.stargazers")
+	ctx.Data["CardsTitle"] = ctx.Tr("repo.stargazers")
 	ctx.Data["PageIsStargazers"] = true
-	renderItems(ctx, ctx.Repo.Repository.NumStars, ctx.Repo.Repository.GetStargazers)
+	RenderUserCards(ctx, ctx.Repo.Repository.NumStars, ctx.Repo.Repository.GetStargazers, WATCHERS)
 }
 
 func Forks(ctx *middleware.Context) {

+ 0 - 63
routers/user/home.go

@@ -7,7 +7,6 @@ package user
 import (
 	"bytes"
 	"fmt"
-	"strings"
 
 	"github.com/Unknwon/com"
 	"github.com/Unknwon/paginater"
@@ -21,7 +20,6 @@ import (
 const (
 	DASHBOARD base.TplName = "user/dashboard/dashboard"
 	ISSUES    base.TplName = "user/dashboard/issues"
-	STARS     base.TplName = "user/stars"
 	PROFILE   base.TplName = "user/profile"
 	ORG_HOME  base.TplName = "org/home"
 )
@@ -338,67 +336,6 @@ func showOrgProfile(ctx *middleware.Context) {
 	ctx.HTML(200, ORG_HOME)
 }
 
-func Profile(ctx *middleware.Context) {
-	ctx.Data["Title"] = "Profile"
-	ctx.Data["PageIsUserProfile"] = true
-
-	uname := ctx.Params(":username")
-	// Special handle for FireFox requests favicon.ico.
-	if uname == "favicon.ico" {
-		ctx.Redirect(setting.AppSubUrl + "/img/favicon.png")
-		return
-	} else if strings.HasSuffix(uname, ".png") {
-		ctx.Error(404)
-		return
-	}
-
-	isShowKeys := false
-	if strings.HasSuffix(uname, ".keys") {
-		isShowKeys = true
-		uname = strings.TrimSuffix(uname, ".keys")
-	}
-
-	u, err := models.GetUserByName(uname)
-	if err != nil {
-		if models.IsErrUserNotExist(err) {
-			ctx.Handle(404, "GetUserByName", err)
-		} else {
-			ctx.Handle(500, "GetUserByName", err)
-		}
-		return
-	}
-
-	// Show SSH keys.
-	if isShowKeys {
-		ShowSSHKeys(ctx, u.Id)
-		return
-	}
-
-	if u.IsOrganization() {
-		showOrgProfile(ctx)
-		return
-	}
-	ctx.Data["Owner"] = u
-
-	tab := ctx.Query("tab")
-	ctx.Data["TabName"] = tab
-	switch tab {
-	case "activity":
-		retrieveFeeds(ctx, u.Id, 0, true)
-		if ctx.Written() {
-			return
-		}
-	default:
-		ctx.Data["Repos"], err = models.GetRepositories(u.Id, ctx.IsSigned && ctx.User.Id == u.Id)
-		if err != nil {
-			ctx.Handle(500, "GetRepositories", err)
-			return
-		}
-	}
-
-	ctx.HTML(200, PROFILE)
-}
-
 func Email2User(ctx *middleware.Context) {
 	u, err := models.GetUserByEmail(ctx.Query("email"))
 	if err != nil {

+ 145 - 0
routers/user/profile.go

@@ -0,0 +1,145 @@
+// Copyright 2015 The Gogs Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package user
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/gogits/gogs/models"
+	"github.com/gogits/gogs/modules/base"
+	"github.com/gogits/gogs/modules/middleware"
+	"github.com/gogits/gogs/modules/setting"
+	"github.com/gogits/gogs/routers/repo"
+)
+
+const (
+	FOLLOWERS base.TplName = "user/meta/followers"
+	STARS     base.TplName = "user/meta/stars"
+)
+
+// GetUserByParams returns user whose name is presented in URL paramenter.
+func GetUserByParams(ctx *middleware.Context) *models.User {
+	user, err := models.GetUserByName(ctx.Params(":username"))
+	if err != nil {
+		if models.IsErrUserNotExist(err) {
+			ctx.Error(404)
+		} else {
+			ctx.Handle(500, "GetUserByName", err)
+		}
+		return nil
+	}
+	return user
+}
+
+func Profile(ctx *middleware.Context) {
+	uname := ctx.Params(":username")
+	// Special handle for FireFox requests favicon.ico.
+	if uname == "favicon.ico" {
+		ctx.Redirect(setting.AppSubUrl + "/img/favicon.png")
+		return
+	} else if strings.HasSuffix(uname, ".png") {
+		ctx.Error(404)
+		return
+	}
+
+	isShowKeys := false
+	if strings.HasSuffix(uname, ".keys") {
+		isShowKeys = true
+	}
+
+	u := GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	// Show SSH keys.
+	if isShowKeys {
+		ShowSSHKeys(ctx, u.Id)
+		return
+	}
+
+	if u.IsOrganization() {
+		showOrgProfile(ctx)
+		return
+	}
+
+	ctx.Data["Title"] = u.DisplayName()
+	ctx.Data["PageIsUserProfile"] = true
+	ctx.Data["Owner"] = u
+
+	tab := ctx.Query("tab")
+	ctx.Data["TabName"] = tab
+	switch tab {
+	case "activity":
+		retrieveFeeds(ctx, u.Id, 0, true)
+		if ctx.Written() {
+			return
+		}
+	default:
+		var err error
+		ctx.Data["Repos"], err = models.GetRepositories(u.Id, ctx.IsSigned && ctx.User.Id == u.Id)
+		if err != nil {
+			ctx.Handle(500, "GetRepositories", err)
+			return
+		}
+	}
+
+	ctx.HTML(200, PROFILE)
+}
+
+func Followers(ctx *middleware.Context) {
+	u := GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+	ctx.Data["Title"] = u.DisplayName()
+	ctx.Data["CardsTitle"] = ctx.Tr("user.followers")
+	ctx.Data["PageIsFollowers"] = true
+	ctx.Data["Owner"] = u
+	repo.RenderUserCards(ctx, u.NumFollowers, u.GetFollowers, FOLLOWERS)
+}
+
+func Following(ctx *middleware.Context) {
+	u := GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+	ctx.Data["Title"] = u.DisplayName()
+	ctx.Data["CardsTitle"] = ctx.Tr("user.following")
+	ctx.Data["PageIsFollowing"] = true
+	ctx.Data["Owner"] = u
+	repo.RenderUserCards(ctx, u.NumFollowing, u.GetFollowing, FOLLOWERS)
+}
+
+func Stars(ctx *middleware.Context) {
+
+}
+
+func Action(ctx *middleware.Context) {
+	u := GetUserByParams(ctx)
+	if ctx.Written() {
+		return
+	}
+
+	var err error
+	switch ctx.Params(":action") {
+	case "follow":
+		err = models.FollowUser(ctx.User.Id, u.Id)
+	case "unfollow":
+		err = models.UnfollowUser(ctx.User.Id, u.Id)
+	}
+
+	if err != nil {
+		ctx.Handle(500, fmt.Sprintf("Action (%s)", ctx.Params(":action")), err)
+		return
+	}
+
+	redirectTo := ctx.Query("redirect_to")
+	if len(redirectTo) == 0 {
+		redirectTo = u.HomeLink()
+	}
+	ctx.Redirect(redirectTo)
+}

+ 1 - 1
templates/.VERSION

@@ -1 +1 @@
-0.8.12.1219
+0.8.13.1221

+ 47 - 0
templates/repo/user_cards.tmpl

@@ -0,0 +1,47 @@
+<div class="ui container user-cards">
+	<h2 class="ui dividing header">
+		{{.CardsTitle}}
+	</h2>
+	<ul class="list">
+		{{range .Cards}}
+			<li class="item ui segment">
+				<a href="{{.HomeLink}}">
+					<img class="avatar" src="{{.AvatarLink}}"/>
+				</a>
+				<h3 class="name"><a href="{{.HomeLink}}">{{.DisplayName}}</a></h3>
+
+				<div class="meta">
+					{{if .Website}}
+						<span class="icon octicon octicon-link"></span> <a href="{{.Website}}" target="_blank">{{.Website}}</a>
+					{{else if .Location}}
+						<span class="icon octicon octicon-location"></span> {{.Location}}
+					{{else}}
+						<span class="icon octicon octicon-clock"></span> {{$.i18n.Tr "user.join_on"}} {{DateFmtShort .Created}}
+					{{end}}
+				</div>
+			</li>
+		{{end}}
+	</ul>
+
+	{{with .Page}}
+		{{if gt .TotalPages 1}}
+			<div class="center page buttons">
+				<div class="ui borderless pagination menu">
+					<a class="{{if not .HasPrevious}}disabled{{end}} item" {{if .HasPrevious}}href="{{$.Link}}?page={{.Previous}}"{{end}}>
+						<i class="left arrow icon"></i> {{$.i18n.Tr "repo.issues.previous"}}
+					</a>
+					{{range .Pages}}
+						{{if eq .Num -1}}
+							<a class="disabled item">...</a>
+						{{else}}
+							<a class="{{if .IsCurrent}}active{{end}} item" {{if not .IsCurrent}}href="{{$.Link}}?page={{.Num}}"{{end}}>{{.Num}}</a>
+						{{end}}
+					{{end}}
+					<a class="{{if not .HasNext}}disabled{{end}} item" {{if .HasNext}}href="{{$.Link}}?page={{.Next}}"{{end}}>
+						{{$.i18n.Tr "repo.issues.next"}}&nbsp;<i class="icon right arrow"></i>
+					</a>
+				</div>
+			</div>
+		{{end}}
+	{{end}}
+</div>

+ 1 - 51
templates/repo/watchers.tmpl

@@ -1,56 +1,6 @@
 {{template "base/head" .}}
 <div class="repository watchers">
 	{{template "repo/header" .}}
-	<div class="ui container">
-		<h2 class="ui dividing header">
-			{{if .PageIsWatchers}}
-				{{.i18n.Tr "repo.watchers"}}
-			{{else}}
-				{{.i18n.Tr "repo.stargazers"}}
-			{{end}}
-		</h2>
-		<ul class="list">
-			{{range .Watchers}}
-				<li class="item ui segment">
-					<a href="{{.HomeLink}}">
-						<img class="avatar" src="{{.AvatarLink}}"/>
-					</a>
-					<h3 class="name"><a href="{{.HomeLink}}">{{.DisplayName}}</a></h3>
-
-					<div class="meta">
-						{{if .Website}}
-							<span class="icon octicon octicon-link"></span> <a href="{{.Website}}" target="_blank">{{.Website}}</a>
-						{{else if .Location}}
-							<span class="icon octicon octicon-location"></span> {{.Location}}
-						{{else}}
-							<span class="icon octicon octicon-clock"></span> {{$.i18n.Tr "user.join_on"}} {{DateFmtShort .Created}}
-						{{end}}
-					</div>
-				</li>
-			{{end}}
-		</ul>
-
-		{{with .Page}}
-			{{if gt .TotalPages 1}}
-				<div class="center page buttons">
-					<div class="ui borderless pagination menu">
-						<a class="{{if not .HasPrevious}}disabled{{end}} item" {{if .HasPrevious}}href="{{$.Link}}?page={{.Previous}}"{{end}}>
-							<i class="left arrow icon"></i> {{$.i18n.Tr "repo.issues.previous"}}
-						</a>
-						{{range .Pages}}
-							{{if eq .Num -1}}
-								<a class="disabled item">...</a>
-							{{else}}
-								<a class="{{if .IsCurrent}}active{{end}} item" {{if not .IsCurrent}}href="{{$.Link}}?page={{.Num}}"{{end}}>{{.Num}}</a>
-							{{end}}
-						{{end}}
-						<a class="{{if not .HasNext}}disabled{{end}} item" {{if .HasNext}}href="{{$.Link}}?page={{.Next}}"{{end}}>
-							{{$.i18n.Tr "repo.issues.next"}}&nbsp;<i class="icon right arrow"></i>
-						</a>
-					</div>
-				</div>
-			{{end}}
-		{{end}}
-	</div>
+	{{template "repo/user_cards" .}}
 </div>
 {{template "base/footer" .}}

+ 6 - 0
templates/user/meta/followers.tmpl

@@ -0,0 +1,6 @@
+{{template "base/head" .}}
+<div class="user followers">
+	{{template "user/meta/header" .}}
+	{{template "repo/user_cards" .}}
+</div>
+{{template "base/footer" .}}

+ 25 - 0
templates/user/meta/header.tmpl

@@ -0,0 +1,25 @@
+{{with .Owner}}
+<div class="ui container">
+	<img class="ui avatar image" src="{{.AvatarLink}}">
+	<span class="header name">
+		<a href="{{.HomeLink}}">{{.Name}}</a>
+		{{with .FullName}}({{.}}){{end}}
+	</span>
+
+	<div class="ui right">
+		
+		{{if or $.PageIsFollowers $.PageIsFollowing}}
+			{{if and $.IsSigned (ne $.SignedUserName .Name)}}
+				<div class="follow">
+					{{if $.SignedUser.IsFollowing .Id}}
+					<a class="ui small basic red button" href="{{.HomeLink}}/action/unfollow?redirect_to={{$.Link}}"><i class="octicon octicon-person"></i> {{$.i18n.Tr "user.unfollow"}}</a>
+					{{else}}
+					<a class="ui small basic green button" href="{{.HomeLink}}/action/follow?redirect_to={{$.Link}}"><i class="octicon octicon-person"></i> {{$.i18n.Tr "user.follow"}}</a>
+					{{end}}
+				</div>
+			{{end}}
+		{{end}}
+	</div>
+</div>
+{{end}}
+<div class="ui divider"></div>

+ 0 - 0
templates/user/meta/stars.tmpl


+ 27 - 0
templates/user/profile.tmpl

@@ -39,6 +39,33 @@
 								</li>
 							{{end}}
 							<li><i class="icon octicon octicon-clock"></i> {{.i18n.Tr "user.join_on"}} {{DateFmtShort .Owner.Created}}</li>
+							<li>
+								<i class="user icon"></i>
+								<a href="{{.Owner.HomeLink}}/followers">
+									{{.Owner.NumFollowers}} {{.i18n.Tr "user.followers"}}
+								</a>
+								-
+								<a href="{{.Owner.HomeLink}}/following">
+									{{.Owner.NumFollowing}} {{.i18n.Tr "user.following"}}
+								</a>
+							</li>
+							{{/*
+							<li>
+								<i class="octicon octicon-star"></i>
+								<a href="{{.Owner.HomeLink}}/stars">
+									{{.Owner.NumStars}} {{.i18n.Tr "user.starred"}}
+								</a>
+							</li>
+							*/}}
+							{{if and .IsSigned (ne .SignedUserName .Owner.Name)}}
+							<li class="follow">
+								{{if .SignedUser.IsFollowing .Owner.Id}}
+								<a class="ui basic red button" href="{{.Link}}/action/unfollow?redirect_to={{$.Link}}"><i class="octicon octicon-person"></i> {{.i18n.Tr "user.unfollow"}}</a>
+								{{else}}
+								<a class="ui basic green button" href="{{.Link}}/action/follow?redirect_to={{$.Link}}"><i class="octicon octicon-person"></i> {{.i18n.Tr "user.follow"}}</a>
+								{{end}}
+							</li>
+							{{end}}
 						</ul>
 					</div>
 				</div>