Browse Source

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.
pull/5340/head
Sergey Dryabzhinsky 7 years ago committed by 无闻
parent
commit
303fa37b60
  1. 2
      Makefile
  2. 10
      cmd/web.go
  3. 2
      conf/app.ini
  4. 62
      models/repo.go
  5. 6
      pkg/setting/setting.go
  6. 12
      public/less/_dashboard.less
  7. 58
      routes/repo/setting.go
  8. 9
      templates/explore/repo_list.tmpl
  9. 1
      templates/org/team/repositories.tmpl
  10. 3
      templates/repo/header.tmpl
  11. 15
      templates/repo/settings/options.tmpl
  12. 8
      templates/user/dashboard/dashboard.tmpl

2
Makefile

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

10
cmd/web.go

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

2
conf/app.ini

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

62
models/repo.go

@ -15,10 +15,14 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"image"
_ "image/jpeg"
"image/png"
"github.com/Unknwon/cae/zip" "github.com/Unknwon/cae/zip"
"github.com/Unknwon/com" "github.com/Unknwon/com"
"github.com/go-xorm/xorm" "github.com/go-xorm/xorm"
"github.com/nfnt/resize"
"github.com/mcuadros/go-version" "github.com/mcuadros/go-version"
log "gopkg.in/clog.v1" log "gopkg.in/clog.v1"
"gopkg.in/ini.v1" "gopkg.in/ini.v1"
@ -27,6 +31,7 @@ import (
api "github.com/gogs/go-gogs-client" api "github.com/gogs/go-gogs-client"
"github.com/gogs/gogs/models/errors" "github.com/gogs/gogs/models/errors"
"github.com/gogs/gogs/pkg/avatar"
"github.com/gogs/gogs/pkg/bindata" "github.com/gogs/gogs/pkg/bindata"
"github.com/gogs/gogs/pkg/markup" "github.com/gogs/gogs/pkg/markup"
"github.com/gogs/gogs/pkg/process" "github.com/gogs/gogs/pkg/process"
@ -284,6 +289,61 @@ func (repo *Repository) HTMLURL() string {
return setting.AppURL + repo.FullName() 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: // This method assumes following fields have been assigned with valid values:
// Required - BaseRepo (if fork) // Required - BaseRepo (if fork)
// Arguments that are allowed to be nil: permission // 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, Created: repo.Created,
Updated: repo.Updated, Updated: repo.Updated,
Permissions: permission, Permissions: permission,
// Reserved for go-gogs-client change
// AvatarUrl: repo.AvatarLink(),
} }
if repo.IsFork { if repo.IsFork {
p := &api.Permission{Pull: true} p := &api.Permission{Pull: true}

6
pkg/setting/setting.go

@ -189,6 +189,7 @@ var (
// Picture settings // Picture settings
AvatarUploadPath string AvatarUploadPath string
RepositoryAvatarUploadPath string
GravatarSource string GravatarSource string
DisableGravatar bool DisableGravatar bool
EnableFederatedAvatar bool EnableFederatedAvatar bool
@ -611,6 +612,11 @@ func NewContext() {
if !filepath.IsAbs(AvatarUploadPath) { if !filepath.IsAbs(AvatarUploadPath) {
AvatarUploadPath = path.Join(workDir, 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 { switch source := sec.Key("GRAVATAR_SOURCE").MustString("gravatar"); source {
case "duoshuo": case "duoshuo":
GravatarSource = "http://gravatar.duoshuo.com/avatar/" GravatarSource = "http://gravatar.duoshuo.com/avatar/"

12
public/less/_dashboard.less

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

58
routes/repo/setting.go

@ -8,9 +8,10 @@ import (
"fmt" "fmt"
"strings" "strings"
"time" "time"
"io/ioutil"
log "gopkg.in/clog.v1" log "gopkg.in/clog.v1"
"github.com/Unknwon/com"
"github.com/gogs/git-module" "github.com/gogs/git-module"
"github.com/gogs/gogs/models" "github.com/gogs/gogs/models"
@ -19,10 +20,12 @@ import (
"github.com/gogs/gogs/pkg/form" "github.com/gogs/gogs/pkg/form"
"github.com/gogs/gogs/pkg/mailer" "github.com/gogs/gogs/pkg/mailer"
"github.com/gogs/gogs/pkg/setting" "github.com/gogs/gogs/pkg/setting"
"github.com/gogs/gogs/pkg/tool"
) )
const ( const (
SETTINGS_OPTIONS = "repo/settings/options" SETTINGS_OPTIONS = "repo/settings/options"
SETTINGS_REPO_AVATAR = "repo/settings/avatar"
SETTINGS_COLLABORATION = "repo/settings/collaboration" SETTINGS_COLLABORATION = "repo/settings/collaboration"
SETTINGS_BRANCHES = "repo/settings/branches" SETTINGS_BRANCHES = "repo/settings/branches"
SETTINGS_PROTECTED_BRANCH = "repo/settings/protected_branch" SETTINGS_PROTECTED_BRANCH = "repo/settings/protected_branch"
@ -632,3 +635,56 @@ func DeleteDeployKey(c *context.Context) {
"redirect": c.Repo.RepoLink + "/settings/keys", "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
}

9
templates/explore/repo_list.tmpl

@ -1,6 +1,11 @@
<div class="ui repository list"> <div class="ui repository list">
{{range .Repos}} {{range .Repos}}
<div class="item"> <div class="item">
<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"> <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> <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}} {{if .IsPrivate}}
@ -9,6 +14,8 @@
<span><i class="octicon octicon-repo-forked"></i></span> <span><i class="octicon octicon-repo-forked"></i></span>
{{else if .IsMirror}} {{else if .IsMirror}}
<span><i class="octicon octicon-repo-clone"></i></span> <span><i class="octicon octicon-repo-clone"></i></span>
{{else}}
<span class="text"><i class="octicon octicon-globe"></i></span>
{{end}} {{end}}
<div class="ui right metas"> <div class="ui right metas">
@ -19,5 +26,7 @@
{{if .Description}}<p class="has-emoji">{{.Description | Str2html}}</p>{{end}} {{if .Description}}<p class="has-emoji">{{.Description | Str2html}}</p>{{end}}
<p class="time">{{$.i18n.Tr "org.repo_updated"}} {{TimeSince .Updated $.i18n.Lang}}</p> <p class="time">{{$.i18n.Tr "org.repo_updated"}} {{TimeSince .Updated $.i18n.Lang}}</p>
</div> </div>
</div>
</div>
{{end}} {{end}}
</div> </div>

1
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> <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}} {{end}}
<a class="member" href="{{AppSubURL}}/{{$.Org.Name}}/{{.Name}}"> <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> <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> <strong>{{$.Org.Name}}/{{.Name}}</strong>
</a> </a>

3
templates/repo/header.tmpl

@ -5,7 +5,8 @@
<div class="column"><!-- start column --> <div class="column"><!-- start column -->
<div class="ui header"> <div class="ui header">
<div class="ui huge breadcrumb"> <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> <a href="{{AppSubURL}}/{{.Owner.Name}}">{{.Owner.Name}}</a>
<div class="divider"> / </div> <div class="divider"> / </div>
<a href="{{$.RepoLink}}">{{.Name}}</a> <a href="{{$.RepoLink}}">{{.Name}}</a>

15
templates/repo/settings/options.tmpl

@ -41,6 +41,21 @@
<div class="field"> <div class="field">
<button class="ui green button">{{$.i18n.Tr "repo.settings.update_settings"}}</button> <button class="ui green button">{{$.i18n.Tr "repo.settings.update_settings"}}</button>
</div> </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> </form>
</div> </div>

8
templates/user/dashboard/dashboard.tmpl

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

Loading…
Cancel
Save