Skip to content

Commit

Permalink
Repository avatars (#6986)
Browse files Browse the repository at this point in the history
* Repository avatars

- first variant of code from old work for gogs
- add migration 87
- add new option in app.ini
- add en-US locale string
- add new class in repository.less

* Add changed index.css, remove unused template name

* Update en-us doc about configuration options

* Add comments to new functions, add new option to docker app.ini

* Add comment for lint

* Remove variable, not needed

* Fix formatting

* Update swagger api template

* Check if avatar exists

* Fix avatar link/path checks

* Typo

* TEXT column can't have a default value

* Fixes:

- remove old avatar file on upload
- use ID in name of avatar file - users may upload same files
- add simple tests

* Fix fmt check

* Generate PNG instead of "static" GIF

* More informative comment

* Fix error message

* Update avatar upload checks:

- add file size check
- add new option
- update config docs
- add new string to en-us locale

* Fixes:

- use FileHEader field for check file size
- add new test - upload big image

* Fix formatting

* Update comments

* Update log message

* Removed wrong style - not needed

* Use Sync2 to migrate

* Update repos list view

- bigger avatar
- fix html blocks alignment

* A little adjust avatar size

* Use small icons for explore/repo list

* Use new cool avatar preparation func by @lafriks

* Missing changes for new function

* Remove unused import, move imports

* Missed new option definition in app.ini

Add file size check in user/profile avatar upload

* Use smaller field length for Avatar

* Use session to update repo DB data, update DeleteAvatar - use session too

* Fix err variable definition

* As suggested @lafriks - return as soon as possible, code readability
  • Loading branch information
sergey-dryabzhinsky authored and techknowlogick committed May 30, 2019
1 parent d749404 commit 3fd1883
Show file tree
Hide file tree
Showing 19 changed files with 354 additions and 20 deletions.
8 changes: 6 additions & 2 deletions custom/conf/app.ini.sample
Original file line number Diff line number Diff line change
Expand Up @@ -504,10 +504,14 @@ SESSION_LIFE_TIME = 86400

[picture]
AVATAR_UPLOAD_PATH = data/avatars
; Max Width and Height of uploaded avatars. This is to limit the amount of RAM
; used when resizing the image.
REPOSITORY_AVATAR_UPLOAD_PATH = data/repo-avatars
; Max Width and Height of uploaded avatars.
; This is to limit the amount of RAM used when resizing the image.
AVATAR_MAX_WIDTH = 4096
AVATAR_MAX_HEIGHT = 3072
; Maximum alloved file size for uploaded avatars.
; This is to limit the amount of RAM used when resizing the image.
AVATAR_MAX_FILE_SIZE = 1048576
; Chinese users can choose "duoshuo"
; or a custom avatar source, like: http://cn.gravatar.com/avatar/
GRAVATAR_SOURCE = gravatar
Expand Down
1 change: 1 addition & 0 deletions docker/root/etc/templates/app.ini
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ PROVIDER_CONFIG = /data/gitea/sessions

[picture]
AVATAR_UPLOAD_PATH = /data/gitea/avatars
REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars

[attachment]
PATH = /data/gitea/attachments
Expand Down
6 changes: 5 additions & 1 deletion docs/content/doc/advanced/config-cheat-sheet.en-us.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,11 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
- `DISABLE_GRAVATAR`: **false**: Enable this to use local avatars only.
- `ENABLE_FEDERATED_AVATAR`: **false**: Enable support for federated avatars (see
[http://www.libravatar.org](http://www.libravatar.org)).
- `AVATAR_UPLOAD_PATH`: **data/avatars**: Path to store local and cached files.
- `AVATAR_UPLOAD_PATH`: **data/avatars**: Path to store user avatar image files.
- `REPOSITORY_AVATAR_UPLOAD_PATH`: **data/repo-avatars**: Path to store repository avatar image files.
- `AVATAR_MAX_WIDTH`: **4096**: Maximum avatar image width in pixels.
- `AVATAR_MAX_HEIGHT`: **3072**: Maximum avatar image height in pixels.
- `AVATAR_MAX_FILE_SIZE`: **1048576** (1Mb): Maximum avatar image file size in bytes.

## Attachment (`attachment`)

Expand Down
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ var migrations = []Migration{
NewMigration("hash application token", hashAppToken),
// v86 -> v87
NewMigration("add http method to webhook", addHTTPMethodToWebhook),
// v87 -> v88
NewMigration("add avatar field to repository", addAvatarFieldToRepository),
}

// Migrate database to current version
Expand Down
18 changes: 18 additions & 0 deletions models/migrations/v87.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright 2019 Gitea. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package migrations

import (
"github.com/go-xorm/xorm"
)

func addAvatarFieldToRepository(x *xorm.Engine) error {
type Repository struct {
// ID(10-20)-md5(32) - must fit into 64 symbols
Avatar string `xorm:"VARCHAR(64)"`
}

return x.Sync2(new(Repository))
}
134 changes: 134 additions & 0 deletions models/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ package models

import (
"bytes"
"crypto/md5"
"errors"
"fmt"
"html/template"

// Needed for jpeg support
_ "image/jpeg"
"image/png"
"io/ioutil"
"net/url"
"os"
Expand All @@ -21,6 +26,7 @@ import (
"strings"
"time"

"code.gitea.io/gitea/modules/avatar"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
Expand Down Expand Up @@ -166,6 +172,9 @@ type Repository struct {
CloseIssuesViaCommitInAnyBranch bool `xorm:"NOT NULL DEFAULT false"`
Topics []string `xorm:"TEXT JSON"`

// Avatar: ID(10-20)-md5(32) - must fit into 64 symbols
Avatar string `xorm:"VARCHAR(64)"`

CreatedUnix util.TimeStamp `xorm:"INDEX created"`
UpdatedUnix util.TimeStamp `xorm:"INDEX updated"`
}
Expand Down Expand Up @@ -290,6 +299,7 @@ func (repo *Repository) innerAPIFormat(e Engine, mode AccessMode, isParent bool)
Created: repo.CreatedUnix.AsTime(),
Updated: repo.UpdatedUnix.AsTime(),
Permissions: permission,
AvatarURL: repo.AvatarLink(),
}
}

Expand Down Expand Up @@ -1869,6 +1879,15 @@ func DeleteRepository(doer *User, uid, repoID int64) error {
go HookQueue.Add(repo.ID)
}

if len(repo.Avatar) > 0 {
avatarPath := repo.CustomAvatarPath()
if com.IsExist(avatarPath) {
if err := os.Remove(avatarPath); err != nil {
return fmt.Errorf("Failed to remove %s: %v", avatarPath, err)
}
}
}

DeleteRepoFromIndexer(repo)
return nil
}
Expand Down Expand Up @@ -2452,3 +2471,118 @@ func (repo *Repository) GetUserFork(userID int64) (*Repository, error) {
}
return &forkedRepo, nil
}

// CustomAvatarPath returns repository custom avatar file path.
func (repo *Repository) CustomAvatarPath() string {
// Avatar empty by default
if len(repo.Avatar) <= 0 {
return ""
}
return filepath.Join(setting.RepositoryAvatarUploadPath, repo.Avatar)
}

// RelAvatarLink returns a relative link to the user's avatar.
// The link a sub-URL to this site
// Since Gravatar support not needed here - just check for image path.
func (repo *Repository) RelAvatarLink() string {
// If no avatar - path is empty
avatarPath := repo.CustomAvatarPath()
if len(avatarPath) <= 0 {
return ""
}
if !com.IsFile(avatarPath) {
return ""
}
return setting.AppSubURL + "/repo-avatars/" + repo.Avatar
}

// AvatarLink returns user avatar absolute link.
func (repo *Repository) AvatarLink() string {
link := repo.RelAvatarLink()
// link may be empty!
if len(link) > 0 {
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 repos.
func (repo *Repository) UploadAvatar(data []byte) error {
m, err := avatar.Prepare(data)
if err != nil {
return err
}

sess := x.NewSession()
defer sess.Close()
if err = sess.Begin(); err != nil {
return err
}

oldAvatarPath := repo.CustomAvatarPath()

// Users can upload the same image to other repo - prefix it with ID
// Then repo will be removed - only it avatar file will be removed
repo.Avatar = fmt.Sprintf("%d-%x", repo.ID, md5.Sum(data))
if _, err := sess.ID(repo.ID).Cols("avatar").Update(repo); err != nil {
return fmt.Errorf("UploadAvatar: Update repository avatar: %v", err)
}

if err := os.MkdirAll(setting.RepositoryAvatarUploadPath, os.ModePerm); err != nil {
return fmt.Errorf("UploadAvatar: Failed to create dir %s: %v", setting.RepositoryAvatarUploadPath, err)
}

fw, err := os.Create(repo.CustomAvatarPath())
if err != nil {
return fmt.Errorf("UploadAvatar: Create file: %v", err)
}
defer fw.Close()

if err = png.Encode(fw, *m); err != nil {
return fmt.Errorf("UploadAvatar: Encode png: %v", err)
}

if len(oldAvatarPath) > 0 && oldAvatarPath != repo.CustomAvatarPath() {
if err := os.Remove(oldAvatarPath); err != nil {
return fmt.Errorf("UploadAvatar: Failed to remove old repo avatar %s: %v", oldAvatarPath, err)
}
}

return sess.Commit()
}

// DeleteAvatar deletes the repos's custom avatar.
func (repo *Repository) DeleteAvatar() error {

// Avatar not exists
if len(repo.Avatar) == 0 {
return nil
}

avatarPath := repo.CustomAvatarPath()
log.Trace("DeleteAvatar[%d]: %s", repo.ID, avatarPath)

sess := x.NewSession()
defer sess.Close()
if err := sess.Begin(); err != nil {
return err
}

repo.Avatar = ""
if _, err := sess.ID(repo.ID).Cols("avatar").Update(repo); err != nil {
return fmt.Errorf("DeleteAvatar: Update repository avatar: %v", err)
}

if _, err := os.Stat(avatarPath); err == nil {
if err := os.Remove(avatarPath); err != nil {
return fmt.Errorf("DeleteAvatar: Failed to remove %s: %v", avatarPath, err)
}
} else {
// // Schrodinger: file may or may not exist. See err for details.
log.Trace("DeleteAvatar[%d]: %v", err)
}
return sess.Commit()
}
53 changes: 53 additions & 0 deletions models/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
package models

import (
"bytes"
"crypto/md5"
"fmt"
"image"
"image/png"
"testing"

"code.gitea.io/gitea/modules/markup"
Expand Down Expand Up @@ -158,3 +163,51 @@ func TestTransferOwnership(t *testing.T) {

CheckConsistencyFor(t, &Repository{}, &User{}, &Team{})
}

func TestUploadAvatar(t *testing.T) {

// Generate image
myImage := image.NewRGBA(image.Rect(0, 0, 1, 1))
var buff bytes.Buffer
png.Encode(&buff, myImage)

assert.NoError(t, PrepareTestDatabase())
repo := AssertExistsAndLoadBean(t, &Repository{ID: 10}).(*Repository)

err := repo.UploadAvatar(buff.Bytes())
assert.NoError(t, err)
assert.Equal(t, fmt.Sprintf("%d-%x", 10, md5.Sum(buff.Bytes())), repo.Avatar)
}

func TestUploadBigAvatar(t *testing.T) {

// Generate BIG image
myImage := image.NewRGBA(image.Rect(0, 0, 5000, 1))
var buff bytes.Buffer
png.Encode(&buff, myImage)

assert.NoError(t, PrepareTestDatabase())
repo := AssertExistsAndLoadBean(t, &Repository{ID: 10}).(*Repository)

err := repo.UploadAvatar(buff.Bytes())
assert.Error(t, err)
}

func TestDeleteAvatar(t *testing.T) {

// Generate image
myImage := image.NewRGBA(image.Rect(0, 0, 1, 1))
var buff bytes.Buffer
png.Encode(&buff, myImage)

assert.NoError(t, PrepareTestDatabase())
repo := AssertExistsAndLoadBean(t, &Repository{ID: 10}).(*Repository)

err := repo.UploadAvatar(buff.Bytes())
assert.NoError(t, err)

err = repo.DeleteAvatar()
assert.NoError(t, err)

assert.Equal(t, "", repo.Avatar)
}
24 changes: 16 additions & 8 deletions modules/setting/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,14 +250,16 @@ var (
}

// Picture settings
AvatarUploadPath string
AvatarMaxWidth int
AvatarMaxHeight int
GravatarSource string
GravatarSourceURL *url.URL
DisableGravatar bool
EnableFederatedAvatar bool
LibravatarService *libravatar.Libravatar
AvatarUploadPath string
AvatarMaxWidth int
AvatarMaxHeight int
GravatarSource string
GravatarSourceURL *url.URL
DisableGravatar bool
EnableFederatedAvatar bool
LibravatarService *libravatar.Libravatar
AvatarMaxFileSize int64
RepositoryAvatarUploadPath string

// Log settings
LogLevel string
Expand Down Expand Up @@ -835,8 +837,14 @@ func NewContext() {
if !filepath.IsAbs(AvatarUploadPath) {
AvatarUploadPath = path.Join(AppWorkPath, AvatarUploadPath)
}
RepositoryAvatarUploadPath = sec.Key("REPOSITORY_AVATAR_UPLOAD_PATH").MustString(path.Join(AppDataPath, "repo-avatars"))
forcePathSeparator(RepositoryAvatarUploadPath)
if !filepath.IsAbs(RepositoryAvatarUploadPath) {
RepositoryAvatarUploadPath = path.Join(AppWorkPath, RepositoryAvatarUploadPath)
}
AvatarMaxWidth = sec.Key("AVATAR_MAX_WIDTH").MustInt(4096)
AvatarMaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(3072)
AvatarMaxFileSize = sec.Key("AVATAR_MAX_FILE_SIZE").MustInt64(1048576)
switch source := sec.Key("GRAVATAR_SOURCE").MustString("gravatar"); source {
case "duoshuo":
GravatarSource = "http://gravatar.duoshuo.com/avatar/"
Expand Down
1 change: 1 addition & 0 deletions modules/structs/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type Repository struct {
// swagger:strfmt date-time
Updated time.Time `json:"updated_at"`
Permissions *Permission `json:"permissions,omitempty"`
AvatarURL string `json:"avatar_url"`
}

// CreateRepoOption options when creating repository
Expand Down
2 changes: 2 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ choose_new_avatar = Choose new avatar
update_avatar = Update Avatar
delete_current_avatar = Delete Current Avatar
uploaded_avatar_not_a_image = The uploaded file is not an image.
uploaded_avatar_is_too_big = The uploaded file has exceeded the maximum size.
update_avatar_success = Your avatar has been updated.

change_password = Update Password
Expand Down Expand Up @@ -1314,6 +1315,7 @@ settings.unarchive.header = Un-Archive This Repo
settings.unarchive.text = Un-Archiving the repo will restore its ability to recieve commits and pushes, as well as new issues and pull-requests.
settings.unarchive.success = The repo was successfully un-archived.
settings.unarchive.error = An error occured while trying to un-archive the repo. See the log for more details.
settings.update_avatar_success = The repository avatar has been updated.
diff.browse_source = Browse Source
diff.parent = parent
Expand Down
1 change: 1 addition & 0 deletions public/css/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,7 @@ tbody.commit-list{vertical-align:baseline}
.ui.repository.list .item .ui.header .metas span:not(:last-child){margin-right:5px}
.ui.repository.list .item .time{font-size:12px;color:grey}
.ui.repository.list .item .ui.tags{margin-bottom:1em}
.ui.repository.list .item .ui.avatar.image{width:24px;height:24px}
.ui.repository.branches .time{font-size:12px;color:grey}
.ui.user.list .item{padding-bottom:25px}
.ui.user.list .item:not(:first-child){border-top:1px solid #eee;padding-top:25px}
Expand Down
5 changes: 5 additions & 0 deletions public/less/_explore.less
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@
.ui.tags {
margin-bottom: 1em;
}

.ui.avatar.image {
width: 24px;
height: 24px;
}
}
}

Expand Down
Loading

0 comments on commit 3fd1883

Please sign in to comment.