Переглянути джерело

repo: support avatars (#5221)

* First code for repository avatars

* Last code for repository avatars

- add new option for repo avatars location on filesystem
- add route catch in web
- add new fields to repo model
- add migration
- update settings handlers
- update repo header template

* Update locale messages

* Add repo avatars to home page

* Add repo avatars to organization right panel

* Show repo avatars in repo list

* Remove AvatarEamil field, remove Gravatar support, use generic locale messages

* Fix migration

* Fix seed and not used tool

* Revert public css changes, add them to less files

* Latest lessc (2.6.0) don't put result into file but output to stdout

So redirect output to file

* Simplify things:

- migration don't needed, and table changes too
- just upload file to repo avatar storage
- or generate random image

* Fix repo image seed - name not unique

* Get rid of not needed model fields

* Class value is enough, remove height attribute

* Don't generate random avatar for repository

- use html and semantic ui icons if no avatar found

* Update styles and templates for repo

- use repo icon as default avatar
- use globe icon for public repos
- add micro style for repo avatars at dashboard

* Remvoe redundant empty line

* Fix nl2br filter - must return string

* Fix css style for micro-repo-avatar in dashboard list

* Remove `|len`, works fine w/o it.

* Update after review 2:

- use static route for repository avatar
- format images settings block in settings

* Update after review 2:

- no random avatar for repo

* Update after review 2:

- no random avatar for repo 2
- update imports
- update UploadAvatar* functions

* Update after review 2:

- update templates

* Fix trace call

* Remove unused immport since we use static route for repo avatars.
Sergey Dryabzhinsky 6 роки тому
батько
коміт
303fa37b60

+ 1 - 1
Makefile

@@ -62,7 +62,7 @@ pkg/bindata/bindata.go: $(DATA_FILES)
 less: public/css/gogs.css
 
 public/css/gogs.css: $(LESS_FILES)
-	lessc $< $@
+	lessc $< >$@
 
 clean:
 	go clean -i ./...

+ 10 - 0
cmd/web.go

@@ -100,6 +100,13 @@ func newMacaron() *macaron.Macaron {
 			SkipLogging: setting.DisableRouterLog,
 		},
 	))
+	m.Use(macaron.Static(
+		setting.RepositoryAvatarUploadPath,
+		macaron.StaticOptions{
+			Prefix:      "repo-avatars",
+			SkipLogging: setting.DisableRouterLog,
+		},
+	))
 
 	funcMap := template.NewFuncMap()
 	m.Use(macaron.Renderer(macaron.RenderOptions{
@@ -419,6 +426,9 @@ func runWeb(c *cli.Context) error {
 		m.Group("/settings", func() {
 			m.Combo("").Get(repo.Settings).
 				Post(bindIgnErr(form.RepoSetting{}), repo.SettingsPost)
+			m.Combo("/avatar").Get(repo.SettingsAvatar).
+				Post(binding.MultipartForm(form.Avatar{}), repo.SettingsAvatarPost)
+			m.Post("/avatar/delete", repo.SettingsDeleteAvatar)
 			m.Group("/collaboration", func() {
 				m.Combo("").Get(repo.SettingsCollaboration).Post(repo.SettingsCollaborationPost)
 				m.Post("/access_mode", repo.ChangeCollaborationAccessMode)

+ 2 - 0
conf/app.ini

@@ -286,6 +286,8 @@ CSRF_COOKIE_NAME = _csrf
 [picture]
 ; Path to store user uploaded avatars
 AVATAR_UPLOAD_PATH = data/avatars
+; Path to store repository uploaded avatars
+REPOSITORY_AVATAR_UPLOAD_PATH = data/repo-avatars
 ; Chinese users can choose "duoshuo"
 ; or a custom avatar source, like: http://cn.gravatar.com/avatar/
 GRAVATAR_SOURCE = gravatar

+ 62 - 0
models/repo.go

@@ -15,10 +15,14 @@ import (
 	"sort"
 	"strings"
 	"time"
+	"image"
+	_ "image/jpeg"
+	"image/png"
 
 	"github.com/Unknwon/cae/zip"
 	"github.com/Unknwon/com"
 	"github.com/go-xorm/xorm"
+	"github.com/nfnt/resize"
 	"github.com/mcuadros/go-version"
 	log "gopkg.in/clog.v1"
 	"gopkg.in/ini.v1"
@@ -27,6 +31,7 @@ import (
 	api "github.com/gogs/go-gogs-client"
 
 	"github.com/gogs/gogs/models/errors"
+	"github.com/gogs/gogs/pkg/avatar"
 	"github.com/gogs/gogs/pkg/bindata"
 	"github.com/gogs/gogs/pkg/markup"
 	"github.com/gogs/gogs/pkg/process"
@@ -284,6 +289,61 @@ func (repo *Repository) HTMLURL() string {
 	return setting.AppURL + repo.FullName()
 }
 
+// CustomAvatarPath returns repository custom avatar file path.
+func (repo *Repository) CustomAvatarPath() string {
+	return filepath.Join(setting.RepositoryAvatarUploadPath, com.ToStr(repo.ID))
+}
+
+// RelAvatarLink returns relative avatar link to the site domain,
+// which includes app sub-url as prefix.
+// Since Gravatar support not needed here - just check for image path.
+func (repo *Repository) RelAvatarLink() string {
+	defaultImgUrl := ""
+	if !com.IsExist(repo.CustomAvatarPath()) {
+		return defaultImgUrl
+	}
+	return setting.AppSubURL + "/repo-avatars/" + com.ToStr(repo.ID)
+}
+
+// AvatarLink returns user avatar absolute link.
+func (repo *Repository) AvatarLink() string {
+	link := repo.RelAvatarLink()
+	if link[0] == '/' && link[1] != '/' {
+		return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL)[1:]
+	}
+	return link
+}
+
+// UploadAvatar saves custom avatar for repository.
+// FIXME: split uploads to different subdirs
+// in case we have massive number of repositories.
+func (repo *Repository) UploadAvatar(data []byte) error {
+	img, _, err := image.Decode(bytes.NewReader(data))
+	if err != nil {
+		return fmt.Errorf("Decode: %v", err)
+	}
+
+	m := resize.Resize(avatar.AVATAR_SIZE, avatar.AVATAR_SIZE, img, resize.NearestNeighbor)
+	os.MkdirAll(setting.RepositoryAvatarUploadPath, os.ModePerm)
+	fw, err := os.Create(repo.CustomAvatarPath())
+	if err != nil {
+		return fmt.Errorf("Create: %v", err)
+	}
+	defer fw.Close()
+
+	if err = png.Encode(fw, m); err != nil {
+		return fmt.Errorf("Encode: %v", err)
+	}
+
+	return nil
+}
+
+// DeleteAvatar deletes the repository custom avatar.
+func (repo *Repository) DeleteAvatar() error {
+	log.Trace("DeleteAvatar [%d]: %s", repo.ID, repo.CustomAvatarPath())
+	return os.Remove(repo.CustomAvatarPath())
+}
+
 // This method assumes following fields have been assigned with valid values:
 // Required - BaseRepo (if fork)
 // Arguments that are allowed to be nil: permission
@@ -312,6 +372,8 @@ func (repo *Repository) APIFormat(permission *api.Permission, user ...*User) *ap
 		Created:       repo.Created,
 		Updated:       repo.Updated,
 		Permissions:   permission,
+// Reserved for go-gogs-client change
+//		AvatarUrl:     repo.AvatarLink(),
 	}
 	if repo.IsFork {
 		p := &api.Permission{Pull: true}

+ 11 - 5
pkg/setting/setting.go

@@ -188,11 +188,12 @@ var (
 	}
 
 	// Picture settings
-	AvatarUploadPath      string
-	GravatarSource        string
-	DisableGravatar       bool
-	EnableFederatedAvatar bool
-	LibravatarService     *libravatar.Libravatar
+	AvatarUploadPath      		string
+	RepositoryAvatarUploadPath	string
+	GravatarSource        		string
+	DisableGravatar       		bool
+	EnableFederatedAvatar 		bool
+	LibravatarService     		*libravatar.Libravatar
 
 	// Log settings
 	LogRootPath string
@@ -611,6 +612,11 @@ func NewContext() {
 	if !filepath.IsAbs(AvatarUploadPath) {
 		AvatarUploadPath = path.Join(workDir, AvatarUploadPath)
 	}
+	RepositoryAvatarUploadPath = sec.Key("REPOSITORY_AVATAR_UPLOAD_PATH").MustString(path.Join(AppDataPath, "repo-avatars"))
+	forcePathSeparator(RepositoryAvatarUploadPath)
+	if !filepath.IsAbs(RepositoryAvatarUploadPath) {
+		RepositoryAvatarUploadPath = path.Join(workDir, RepositoryAvatarUploadPath)
+	}
 	switch source := sec.Key("GRAVATAR_SOURCE").MustString("gravatar"); source {
 	case "duoshuo":
 		GravatarSource = "http://gravatar.duoshuo.com/avatar/"

+ 14 - 4
public/less/_dashboard.less

@@ -141,18 +141,28 @@
 		.repo-owner-name-list {
 			.item-name {
 				max-width: 70%;
-		    margin-bottom: -4px;
+				margin-bottom: -4px;
+			}
+			.ui.micro.image {
+				width: 16px;
+				height: auto;
+				display: inline-block;
 			}
 		}
 
 		#collaborative-repo-list {
 			.owner-and-repo {
-				max-width: 80%;
-		    margin-bottom: -5px;
+				max-width: 75%;
+				margin-bottom: -5px;
 			}
 			.owner-name {
 				max-width: 120px;
-		    margin-bottom: -5px;
+				margin-bottom: -5px;
+			}
+			.ui.micro.image {
+				width: 16px;
+				height: auto;
+				display: inline-block;
 			}
 		}
 	}

+ 57 - 1
routes/repo/setting.go

@@ -8,9 +8,10 @@ import (
 	"fmt"
 	"strings"
 	"time"
+	"io/ioutil"
 
 	log "gopkg.in/clog.v1"
-
+	"github.com/Unknwon/com"
 	"github.com/gogs/git-module"
 
 	"github.com/gogs/gogs/models"
@@ -19,10 +20,12 @@ import (
 	"github.com/gogs/gogs/pkg/form"
 	"github.com/gogs/gogs/pkg/mailer"
 	"github.com/gogs/gogs/pkg/setting"
+	"github.com/gogs/gogs/pkg/tool"
 )
 
 const (
 	SETTINGS_OPTIONS          = "repo/settings/options"
+	SETTINGS_REPO_AVATAR      = "repo/settings/avatar"
 	SETTINGS_COLLABORATION    = "repo/settings/collaboration"
 	SETTINGS_BRANCHES         = "repo/settings/branches"
 	SETTINGS_PROTECTED_BRANCH = "repo/settings/protected_branch"
@@ -632,3 +635,56 @@ func DeleteDeployKey(c *context.Context) {
 		"redirect": c.Repo.RepoLink + "/settings/keys",
 	})
 }
+
+func SettingsAvatar(c *context.Context) {
+	c.Title("settings.avatar")
+	c.PageIs("SettingsAvatar")
+	c.Success(SETTINGS_REPO_AVATAR)
+}
+
+func SettingsAvatarPost(c *context.Context, f form.Avatar) {
+	f.Source = form.AVATAR_LOCAL
+	if err := UpdateAvatarSetting(c, f); err != nil {
+		c.Flash.Error(err.Error())
+	} else {
+		c.Flash.Success(c.Tr("settings.update_avatar_success"))
+	}
+	c.SubURLRedirect(c.Repo.RepoLink + "/settings")
+}
+
+func SettingsDeleteAvatar(c *context.Context) {
+	if err := c.Repo.Repository.DeleteAvatar(); err != nil {
+		c.Flash.Error(fmt.Sprintf("DeleteAvatar: %v", err))
+	}
+	c.SubURLRedirect(c.Repo.RepoLink + "/settings")
+}
+
+// FIXME: limit size.
+func UpdateAvatarSetting(c *context.Context, f form.Avatar) error {
+	ctxRepo := c.Repo.Repository;
+	if f.Avatar != nil {
+		r, err := f.Avatar.Open()
+		if err != nil {
+			return fmt.Errorf("Avatar.Open: %v", err)
+		}
+		defer r.Close()
+
+		data, err := ioutil.ReadAll(r)
+		if err != nil {
+			return fmt.Errorf("ioutil.ReadAll: %v", err)
+		}
+		if !tool.IsImageFile(data) {
+			return errors.New(c.Tr("settings.uploaded_avatar_not_a_image"))
+		}
+		if err = ctxRepo.UploadAvatar(data); err != nil {
+			return fmt.Errorf("UploadAvatar: %v", err)
+		}
+	} else {
+		// No avatar is uploaded but setting has been changed to enable
+		// No random avatar here.
+		if !com.IsFile(ctxRepo.CustomAvatarPath()) {
+			log.Trace("No avatar was uploaded for repo: %d. Default icon will appear instead.", ctxRepo.ID)
+		}
+	}
+	return nil
+}

+ 10 - 1
templates/explore/repo_list.tmpl

@@ -1,7 +1,12 @@
 <div class="ui repository list">
 	{{range .Repos}}
 		<div class="item">
-			<div class="ui header">
+			<div class="ui grid">
+				<div class="ui two wide column middle aligned">
+					{{if .RelAvatarLink}}<img class="ui tiny image" src="{{.RelAvatarLink}}">{{else}}<i class="mega-octicon octicon-repo"></i>{{end}}
+				</div>
+				<div class="ui fourteen wide column">
+				<div class="ui header">
 				<a class="name" href="{{AppSubURL}}/{{if .Owner}}{{.Owner.Name}}{{else if $.Org}}{{$.Org.Name}}{{else}}{{$.Owner.Name}}{{end}}/{{.Name}}">{{if $.PageIsExplore}}{{.Owner.Name}} / {{end}}{{.Name}}</a>
 				{{if .IsPrivate}}
 					<span class="text gold"><i class="octicon octicon-lock"></i></span>
@@ -9,6 +14,8 @@
 					<span><i class="octicon octicon-repo-forked"></i></span>
 				{{else if .IsMirror}}
 					<span><i class="octicon octicon-repo-clone"></i></span>
+				{{else}}
+					<span class="text"><i class="octicon octicon-globe"></i></span>
 				{{end}}
 
 				<div class="ui right metas">
@@ -18,6 +25,8 @@
 			</div>
 			{{if .Description}}<p class="has-emoji">{{.Description | Str2html}}</p>{{end}}
 			<p class="time">{{$.i18n.Tr "org.repo_updated"}} {{TimeSince .Updated $.i18n.Lang}}</p>
+			</div>
+			</div>
 		</div>
 	{{end}}
 </div>

+ 1 - 0
templates/org/team/repositories.tmpl

@@ -17,6 +17,7 @@
 								<a class="ui red small button right" href="{{$.OrgLink}}/teams/{{$.Team.LowerName}}/action/repo/remove?repoid={{.ID}}">{{$.i18n.Tr "org.teams.remove_repo"}}</a>
 							{{end}}
 							<a class="member" href="{{AppSubURL}}/{{$.Org.Name}}/{{.Name}}">
+								<img height="16px" class="octicon" src="{{.RelAvatarLink}}" />
 								<i class="octicon octicon-{{if .IsPrivate}}lock{{else if .IsFork}}repo-forked{{else if .IsMirror}}repo-clone{{else}}repo{{end}}"></i>
 								<strong>{{$.Org.Name}}/{{.Name}}</strong>
 							</a>

+ 2 - 1
templates/repo/header.tmpl

@@ -5,7 +5,8 @@
 			<div class="column"><!-- start column -->
 				<div class="ui header">
 					<div class="ui huge breadcrumb">
-						<i class="mega-octicon octicon-{{if .IsPrivate}}lock{{else if .IsMirror}}repo-clone{{else if .IsFork}}repo-forked{{else}}repo{{end}}"></i>
+						{{if .RelAvatarLink}}<img class="ui mini spaced image" src="{{.RelAvatarLink}}">{{else}}<i class="mega-octicon octicon-repo"></i>{{end}}
+						<i class="mega-octicon octicon-{{if .IsPrivate}}lock{{else if .IsMirror}}repo-clone{{else if .IsFork}}repo-forked{{else}}globe{{end}}"></i>
 						<a href="{{AppSubURL}}/{{.Owner.Name}}">{{.Owner.Name}}</a>
 						<div class="divider"> / </div>
 						<a href="{{$.RepoLink}}">{{.Name}}</a>

+ 15 - 0
templates/repo/settings/options.tmpl

@@ -41,6 +41,21 @@
 						<div class="field">
 							<button class="ui green button">{{$.i18n.Tr "repo.settings.update_settings"}}</button>
 						</div>
+
+					</form>
+
+					<div class="ui divider"></div>
+
+					<form class="ui form" action="{{.Link}}/avatar" method="post" enctype="multipart/form-data">
+						{{.CSRFTokenHTML}}
+						<div class="inline field">
+							<label for="avatar">{{.i18n.Tr "settings.choose_new_avatar"}}</label>
+							<input name="avatar" type="file" >
+						</div>
+						<div class="field">
+							<button class="ui green button">{{$.i18n.Tr "settings.update_avatar"}}</button>
+							<a class="ui red button delete-post" data-request-url="{{.Link}}/avatar/delete" data-done-url="{{.Link}}">{{$.i18n.Tr "settings.delete_current_avatar"}}</a>
+						</div>
 					</form>
 				</div>
 

+ 6 - 2
templates/user/dashboard/dashboard.tmpl

@@ -32,7 +32,8 @@
 							{{range .Repos}}
 								<li {{if .IsPrivate}}class="private"{{end}}>
 									<a href="{{AppSubURL}}/{{$.ContextUser.Name}}/{{.Name}}">
-										<i class="octicon octicon-{{if .IsFork}}repo-forked{{else if .IsPrivate}}lock{{else if .IsMirror}}repo-clone{{else}}repo{{end}}"></i>
+										{{if .RelAvatarLink}}<img class="ui micro image" src="{{.RelAvatarLink}}" />{{else}}<i class="octicon octicon-repo"></i>{{end}}
+										<i class="octicon octicon-{{if .IsFork}}repo-forked{{else if .IsPrivate}}lock{{else if .IsMirror}}repo-clone{{else}}globe{{end}}"></i>
 										<strong class="text truncate item-name">{{.Name}}</strong>
 										<span class="ui right text light grey">
 											{{.NumStars}} <i class="octicon octicon-star rear"></i>
@@ -57,7 +58,8 @@
 								{{range .CollaborativeRepos}}
 									<li {{if .IsPrivate}}class="private"{{end}}>
 										<a href="{{AppSubURL}}/{{.Owner.Name}}/{{.Name}}">
-											<i class="octicon octicon-{{if .IsPrivate}}lock{{else if .IsFork}}repo-forked{{else if .IsMirror}}repo-clone{{else}}repo{{end}}"></i>
+											{{if .RelAvatarLink}}<img class="ui micro image" src="{{.RelAvatarLink}}" />{{else}}<i class="octicon octicon-repo"></i>{{end}}
+											<i class="octicon octicon-{{if .IsPrivate}}lock{{else if .IsFork}}repo-forked{{else if .IsMirror}}repo-clone{{else}}globe{{end}}"></i>
 											<span class="text truncate owner-and-repo">
 												<span class="text truncate owner-name">{{.Owner.Name}}</span> / <strong>{{.Name}}</strong>
 											</span>
@@ -88,6 +90,7 @@
 								{{range .ContextUser.Orgs}}
 									<li>
 										<a href="{{AppSubURL}}/{{.Name}}">
+											{{if .RelAvatarLink}}<img class="ui micro image" src="{{.RelAvatarLink}}" />{{else}}<i class="octicon octicon-repo"></i>{{end}}
 											<i class="octicon octicon-organization"></i>
 											<strong class="text truncate item-name">{{.Name}}</strong>
 											<span class="ui right text light grey">
@@ -116,6 +119,7 @@
 							{{range .Mirrors}}
 								<li {{if .IsPrivate}}class="private"{{end}}>
 									<a href="{{AppSubURL}}/{{$.ContextUser.Name}}/{{.Name}}">
+										{{if .RelAvatarLink}}<img class="ui micro image" src="{{.RelAvatarLink}}" />{{else}}<i class="octicon octicon-repo"></i>{{end}}
 										<i class="octicon octicon-repo-clone"></i>
 										<strong class="text truncate item-name">{{.Name}}</strong>
 										<span class="ui right text light grey">