Repository avatars (#6986)
* 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
This commit is contained in:
		
							parent
							
								
									d7494046ac
								
							
						
					
					
						commit
						3fd18838aa
					
				
					 19 changed files with 354 additions and 20 deletions
				
			
		|  | @ -504,10 +504,14 @@ SESSION_LIFE_TIME = 86400 | ||||||
| 
 | 
 | ||||||
| [picture] | [picture] | ||||||
| AVATAR_UPLOAD_PATH = data/avatars | AVATAR_UPLOAD_PATH = data/avatars | ||||||
| ; Max Width and Height of uploaded avatars. This is to limit the amount of RAM | REPOSITORY_AVATAR_UPLOAD_PATH = data/repo-avatars | ||||||
| ; used when resizing the image. | ; 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_WIDTH = 4096 | ||||||
| AVATAR_MAX_HEIGHT = 3072 | 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" | ; 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 | ||||||
|  |  | ||||||
|  | @ -35,6 +35,7 @@ PROVIDER_CONFIG = /data/gitea/sessions | ||||||
| 
 | 
 | ||||||
| [picture] | [picture] | ||||||
| AVATAR_UPLOAD_PATH = /data/gitea/avatars | AVATAR_UPLOAD_PATH = /data/gitea/avatars | ||||||
|  | REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars | ||||||
| 
 | 
 | ||||||
| [attachment] | [attachment] | ||||||
| PATH = /data/gitea/attachments | PATH = /data/gitea/attachments | ||||||
|  |  | ||||||
|  | @ -290,7 +290,11 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`. | ||||||
| - `DISABLE_GRAVATAR`: **false**: Enable this to use local avatars only. | - `DISABLE_GRAVATAR`: **false**: Enable this to use local avatars only. | ||||||
| - `ENABLE_FEDERATED_AVATAR`: **false**: Enable support for federated avatars (see | - `ENABLE_FEDERATED_AVATAR`: **false**: Enable support for federated avatars (see | ||||||
|    [http://www.libravatar.org](http://www.libravatar.org)). |    [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`) | ## Attachment (`attachment`) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -227,6 +227,8 @@ var migrations = []Migration{ | ||||||
| 	NewMigration("hash application token", hashAppToken), | 	NewMigration("hash application token", hashAppToken), | ||||||
| 	// v86 -> v87
 | 	// v86 -> v87
 | ||||||
| 	NewMigration("add http method to webhook", addHTTPMethodToWebhook), | 	NewMigration("add http method to webhook", addHTTPMethodToWebhook), | ||||||
|  | 	// v87 -> v88
 | ||||||
|  | 	NewMigration("add avatar field to repository", addAvatarFieldToRepository), | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Migrate database to current version
 | // Migrate database to current version
 | ||||||
|  |  | ||||||
							
								
								
									
										18
									
								
								models/migrations/v87.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								models/migrations/v87.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -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
									
								
								models/repo.go
									
									
									
									
									
								
							
							
						
						
									
										134
									
								
								models/repo.go
									
									
									
									
									
								
							|  | @ -7,9 +7,14 @@ package models | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"bytes" | 	"bytes" | ||||||
|  | 	"crypto/md5" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"html/template" | 	"html/template" | ||||||
|  | 
 | ||||||
|  | 	// Needed for jpeg support
 | ||||||
|  | 	_ "image/jpeg" | ||||||
|  | 	"image/png" | ||||||
| 	"io/ioutil" | 	"io/ioutil" | ||||||
| 	"net/url" | 	"net/url" | ||||||
| 	"os" | 	"os" | ||||||
|  | @ -21,6 +26,7 @@ import ( | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
|  | 	"code.gitea.io/gitea/modules/avatar" | ||||||
| 	"code.gitea.io/gitea/modules/git" | 	"code.gitea.io/gitea/modules/git" | ||||||
| 	"code.gitea.io/gitea/modules/log" | 	"code.gitea.io/gitea/modules/log" | ||||||
| 	"code.gitea.io/gitea/modules/markup" | 	"code.gitea.io/gitea/modules/markup" | ||||||
|  | @ -166,6 +172,9 @@ type Repository struct { | ||||||
| 	CloseIssuesViaCommitInAnyBranch bool               `xorm:"NOT NULL DEFAULT false"` | 	CloseIssuesViaCommitInAnyBranch bool               `xorm:"NOT NULL DEFAULT false"` | ||||||
| 	Topics                          []string           `xorm:"TEXT JSON"` | 	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"` | 	CreatedUnix util.TimeStamp `xorm:"INDEX created"` | ||||||
| 	UpdatedUnix util.TimeStamp `xorm:"INDEX updated"` | 	UpdatedUnix util.TimeStamp `xorm:"INDEX updated"` | ||||||
| } | } | ||||||
|  | @ -290,6 +299,7 @@ func (repo *Repository) innerAPIFormat(e Engine, mode AccessMode, isParent bool) | ||||||
| 		Created:       repo.CreatedUnix.AsTime(), | 		Created:       repo.CreatedUnix.AsTime(), | ||||||
| 		Updated:       repo.UpdatedUnix.AsTime(), | 		Updated:       repo.UpdatedUnix.AsTime(), | ||||||
| 		Permissions:   permission, | 		Permissions:   permission, | ||||||
|  | 		AvatarURL:     repo.AvatarLink(), | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -1869,6 +1879,15 @@ func DeleteRepository(doer *User, uid, repoID int64) error { | ||||||
| 		go HookQueue.Add(repo.ID) | 		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) | 	DeleteRepoFromIndexer(repo) | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  | @ -2452,3 +2471,118 @@ func (repo *Repository) GetUserFork(userID int64) (*Repository, error) { | ||||||
| 	} | 	} | ||||||
| 	return &forkedRepo, nil | 	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() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -5,6 +5,11 @@ | ||||||
| package models | package models | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"bytes" | ||||||
|  | 	"crypto/md5" | ||||||
|  | 	"fmt" | ||||||
|  | 	"image" | ||||||
|  | 	"image/png" | ||||||
| 	"testing" | 	"testing" | ||||||
| 
 | 
 | ||||||
| 	"code.gitea.io/gitea/modules/markup" | 	"code.gitea.io/gitea/modules/markup" | ||||||
|  | @ -158,3 +163,51 @@ func TestTransferOwnership(t *testing.T) { | ||||||
| 
 | 
 | ||||||
| 	CheckConsistencyFor(t, &Repository{}, &User{}, &Team{}) | 	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) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -250,14 +250,16 @@ var ( | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Picture settings
 | 	// Picture settings
 | ||||||
| 	AvatarUploadPath      string | 	AvatarUploadPath           string | ||||||
| 	AvatarMaxWidth        int | 	AvatarMaxWidth             int | ||||||
| 	AvatarMaxHeight       int | 	AvatarMaxHeight            int | ||||||
| 	GravatarSource        string | 	GravatarSource             string | ||||||
| 	GravatarSourceURL     *url.URL | 	GravatarSourceURL          *url.URL | ||||||
| 	DisableGravatar       bool | 	DisableGravatar            bool | ||||||
| 	EnableFederatedAvatar bool | 	EnableFederatedAvatar      bool | ||||||
| 	LibravatarService     *libravatar.Libravatar | 	LibravatarService          *libravatar.Libravatar | ||||||
|  | 	AvatarMaxFileSize          int64 | ||||||
|  | 	RepositoryAvatarUploadPath string | ||||||
| 
 | 
 | ||||||
| 	// Log settings
 | 	// Log settings
 | ||||||
| 	LogLevel           string | 	LogLevel           string | ||||||
|  | @ -835,8 +837,14 @@ func NewContext() { | ||||||
| 	if !filepath.IsAbs(AvatarUploadPath) { | 	if !filepath.IsAbs(AvatarUploadPath) { | ||||||
| 		AvatarUploadPath = path.Join(AppWorkPath, 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) | 	AvatarMaxWidth = sec.Key("AVATAR_MAX_WIDTH").MustInt(4096) | ||||||
| 	AvatarMaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(3072) | 	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 { | 	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/" | ||||||
|  |  | ||||||
|  | @ -43,6 +43,7 @@ type Repository struct { | ||||||
| 	// swagger:strfmt date-time
 | 	// swagger:strfmt date-time
 | ||||||
| 	Updated     time.Time   `json:"updated_at"` | 	Updated     time.Time   `json:"updated_at"` | ||||||
| 	Permissions *Permission `json:"permissions,omitempty"` | 	Permissions *Permission `json:"permissions,omitempty"` | ||||||
|  | 	AvatarURL   string      `json:"avatar_url"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // CreateRepoOption options when creating repository
 | // CreateRepoOption options when creating repository
 | ||||||
|  |  | ||||||
|  | @ -389,6 +389,7 @@ choose_new_avatar = Choose new avatar | ||||||
| update_avatar = Update Avatar | update_avatar = Update Avatar | ||||||
| delete_current_avatar = Delete Current Avatar | delete_current_avatar = Delete Current Avatar | ||||||
| uploaded_avatar_not_a_image = The uploaded file is not an image. | 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. | update_avatar_success = Your avatar has been updated. | ||||||
| 
 | 
 | ||||||
| change_password = Update Password | change_password = Update Password | ||||||
|  | @ -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.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.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.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.browse_source = Browse Source | ||||||
| diff.parent = parent | diff.parent = parent | ||||||
|  |  | ||||||
|  | @ -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 .ui.header .metas span:not(:last-child){margin-right:5px} | ||||||
| .ui.repository.list .item .time{font-size:12px;color:grey} | .ui.repository.list .item .time{font-size:12px;color:grey} | ||||||
| .ui.repository.list .item .ui.tags{margin-bottom:1em} | .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.repository.branches .time{font-size:12px;color:grey} | ||||||
| .ui.user.list .item{padding-bottom:25px} | .ui.user.list .item{padding-bottom:25px} | ||||||
| .ui.user.list .item:not(:first-child){border-top:1px solid #eee;padding-top:25px} | .ui.user.list .item:not(:first-child){border-top:1px solid #eee;padding-top:25px} | ||||||
|  |  | ||||||
|  | @ -53,6 +53,11 @@ | ||||||
|         .ui.tags { |         .ui.tags { | ||||||
|             margin-bottom: 1em; |             margin-bottom: 1em; | ||||||
|         } |         } | ||||||
|  | 
 | ||||||
|  |         .ui.avatar.image { | ||||||
|  |             width: 24px; | ||||||
|  |             height: 24px; | ||||||
|  |         } | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,11 +7,14 @@ package repo | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"errors" | 	"errors" | ||||||
|  | 	"fmt" | ||||||
|  | 	"io/ioutil" | ||||||
| 	"net/url" | 	"net/url" | ||||||
| 	"regexp" | 	"regexp" | ||||||
| 	"strings" | 	"strings" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
|  | 	"github.com/Unknwon/com" | ||||||
| 	"mvdan.cc/xurls/v2" | 	"mvdan.cc/xurls/v2" | ||||||
| 
 | 
 | ||||||
| 	"code.gitea.io/gitea/models" | 	"code.gitea.io/gitea/models" | ||||||
|  | @ -727,3 +730,59 @@ func init() { | ||||||
| 		panic(err) | 		panic(err) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // UpdateAvatarSetting update repo's avatar
 | ||||||
|  | func UpdateAvatarSetting(ctx *context.Context, form auth.AvatarForm) error { | ||||||
|  | 	ctxRepo := ctx.Repo.Repository | ||||||
|  | 
 | ||||||
|  | 	if form.Avatar == nil { | ||||||
|  | 		// No avatar is uploaded and we not removing it here.
 | ||||||
|  | 		// No random avatar generated here.
 | ||||||
|  | 		// Just exit, no action.
 | ||||||
|  | 		if !com.IsFile(ctxRepo.CustomAvatarPath()) { | ||||||
|  | 			log.Trace("No avatar was uploaded for repo: %d. Default icon will appear instead.", ctxRepo.ID) | ||||||
|  | 		} | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	r, err := form.Avatar.Open() | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("Avatar.Open: %v", err) | ||||||
|  | 	} | ||||||
|  | 	defer r.Close() | ||||||
|  | 
 | ||||||
|  | 	if form.Avatar.Size > setting.AvatarMaxFileSize { | ||||||
|  | 		return errors.New(ctx.Tr("settings.uploaded_avatar_is_too_big")) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	data, err := ioutil.ReadAll(r) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("ioutil.ReadAll: %v", err) | ||||||
|  | 	} | ||||||
|  | 	if !base.IsImageFile(data) { | ||||||
|  | 		return errors.New(ctx.Tr("settings.uploaded_avatar_not_a_image")) | ||||||
|  | 	} | ||||||
|  | 	if err = ctxRepo.UploadAvatar(data); err != nil { | ||||||
|  | 		return fmt.Errorf("UploadAvatar: %v", err) | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SettingsAvatar save new POSTed repository avatar
 | ||||||
|  | func SettingsAvatar(ctx *context.Context, form auth.AvatarForm) { | ||||||
|  | 	form.Source = auth.AvatarLocal | ||||||
|  | 	if err := UpdateAvatarSetting(ctx, form); err != nil { | ||||||
|  | 		ctx.Flash.Error(err.Error()) | ||||||
|  | 	} else { | ||||||
|  | 		ctx.Flash.Success(ctx.Tr("repo.settings.update_avatar_success")) | ||||||
|  | 	} | ||||||
|  | 	ctx.Redirect(ctx.Repo.RepoLink + "/settings") | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SettingsDeleteAvatar delete repository avatar
 | ||||||
|  | func SettingsDeleteAvatar(ctx *context.Context) { | ||||||
|  | 	if err := ctx.Repo.Repository.DeleteAvatar(); err != nil { | ||||||
|  | 		ctx.Flash.Error(fmt.Sprintf("DeleteAvatar: %v", err)) | ||||||
|  | 	} | ||||||
|  | 	ctx.Redirect(ctx.Repo.RepoLink + "/settings") | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -159,6 +159,14 @@ func NewMacaron() *macaron.Macaron { | ||||||
| 			ExpiresAfter: time.Hour * 6, | 			ExpiresAfter: time.Hour * 6, | ||||||
| 		}, | 		}, | ||||||
| 	)) | 	)) | ||||||
|  | 	m.Use(public.StaticHandler( | ||||||
|  | 		setting.RepositoryAvatarUploadPath, | ||||||
|  | 		&public.Options{ | ||||||
|  | 			Prefix:       "repo-avatars", | ||||||
|  | 			SkipLogging:  setting.DisableRouterLog, | ||||||
|  | 			ExpiresAfter: time.Hour * 6, | ||||||
|  | 		}, | ||||||
|  | 	)) | ||||||
| 
 | 
 | ||||||
| 	m.Use(templates.HTMLRenderer()) | 	m.Use(templates.HTMLRenderer()) | ||||||
| 	models.InitMailRender(templates.Mailer()) | 	models.InitMailRender(templates.Mailer()) | ||||||
|  | @ -613,6 +621,9 @@ func RegisterRoutes(m *macaron.Macaron) { | ||||||
| 		m.Group("/settings", func() { | 		m.Group("/settings", func() { | ||||||
| 			m.Combo("").Get(repo.Settings). | 			m.Combo("").Get(repo.Settings). | ||||||
| 				Post(bindIgnErr(auth.RepoSettingForm{}), repo.SettingsPost) | 				Post(bindIgnErr(auth.RepoSettingForm{}), repo.SettingsPost) | ||||||
|  | 			m.Post("/avatar", binding.MultipartForm(auth.AvatarForm{}), repo.SettingsAvatar) | ||||||
|  | 			m.Post("/avatar/delete", repo.SettingsDeleteAvatar) | ||||||
|  | 
 | ||||||
| 			m.Group("/collaboration", func() { | 			m.Group("/collaboration", func() { | ||||||
| 				m.Combo("").Get(repo.Collaboration).Post(repo.CollaborationPost) | 				m.Combo("").Get(repo.Collaboration).Post(repo.CollaborationPost) | ||||||
| 				m.Post("/access_mode", repo.ChangeCollaborationAccessMode) | 				m.Post("/access_mode", repo.ChangeCollaborationAccessMode) | ||||||
|  |  | ||||||
|  | @ -127,6 +127,10 @@ func UpdateAvatarSetting(ctx *context.Context, form auth.AvatarForm, ctxUser *mo | ||||||
| 		} | 		} | ||||||
| 		defer fr.Close() | 		defer fr.Close() | ||||||
| 
 | 
 | ||||||
|  | 		if form.Avatar.Size > setting.AvatarMaxFileSize { | ||||||
|  | 			return errors.New(ctx.Tr("settings.uploaded_avatar_is_too_big")) | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
| 		data, err := ioutil.ReadAll(fr) | 		data, err := ioutil.ReadAll(fr) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return fmt.Errorf("ioutil.ReadAll: %v", err) | 			return fmt.Errorf("ioutil.ReadAll: %v", err) | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ | ||||||
| 	{{range .Repos}} | 	{{range .Repos}} | ||||||
| 		<div class="item"> | 		<div class="item"> | ||||||
| 			<div class="ui header"> | 			<div class="ui header"> | ||||||
|  | 				<img class="ui avatar image" src="{{.RelAvatarLink}}"> | ||||||
| 				<a class="name" href="{{.Link}}"> | 				<a class="name" href="{{.Link}}"> | ||||||
| 					{{if or $.PageIsExplore $.PageIsProfileStarList }}{{if .Owner}}{{.Owner.Name}} / {{end}}{{end}}{{.Name}} | 					{{if or $.PageIsExplore $.PageIsProfileStarList }}{{if .Owner}}{{.Owner.Name}} / {{end}}{{end}}{{.Name}} | ||||||
| 					{{if .IsArchived}}<i class="archive icon archived-icon"></i>{{end}} | 					{{if .IsArchived}}<i class="archive icon archived-icon"></i>{{end}} | ||||||
|  | @ -14,7 +15,7 @@ | ||||||
| 					<span><i class="octicon octicon-repo-clone"></i></span> | 					<span><i class="octicon octicon-repo-clone"></i></span> | ||||||
| 				{{else if .Owner}} | 				{{else if .Owner}} | ||||||
| 					{{if .Owner.Visibility.IsPrivate}} | 					{{if .Owner.Visibility.IsPrivate}} | ||||||
| 			          <span class="text gold"><i class="octicon octicon-lock"></i></span> | 					<span class="text gold"><i class="octicon octicon-lock"></i></span> | ||||||
| 					{{end}} | 					{{end}} | ||||||
| 				{{end}} | 				{{end}} | ||||||
| 				<div class="ui right metas"> | 				<div class="ui right metas"> | ||||||
|  | @ -22,15 +23,17 @@ | ||||||
| 					<span class="text grey"><i class="octicon octicon-git-branch"></i> {{.NumForks}}</span> | 					<span class="text grey"><i class="octicon octicon-git-branch"></i> {{.NumForks}}</span> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 			{{if .DescriptionHTML}}<p class="has-emoji">{{.DescriptionHTML}}</p>{{end}} | 			<div class="description"> | ||||||
| 			{{if .Topics }} | 				{{if .DescriptionHTML}}<p class="has-emoji">{{.DescriptionHTML}}</p>{{end}} | ||||||
| 				<div class="ui tags"> | 				{{if .Topics }} | ||||||
| 				{{range .Topics}} | 					<div class="ui tags"> | ||||||
| 					{{if ne . "" }}<a href="{{AppSubUrl}}/explore/repos?q={{.}}&topic=1"><div class="ui small label topic">{{.}}</div></a>{{end}} | 					{{range .Topics}} | ||||||
|  | 						{{if ne . "" }}<a href="{{AppSubUrl}}/explore/repos?q={{.}}&topic=1"><div class="ui small label topic">{{.}}</div></a>{{end}} | ||||||
|  | 					{{end}} | ||||||
|  | 					</div> | ||||||
| 				{{end}} | 				{{end}} | ||||||
| 				</div> | 				<p class="time">{{$.i18n.Tr "org.repo_updated"}} {{TimeSinceUnix .UpdatedUnix $.i18n.Lang}}</p> | ||||||
| 			{{end}} | 			</div> | ||||||
| 			<p class="time">{{$.i18n.Tr "org.repo_updated"}} {{TimeSinceUnix .UpdatedUnix $.i18n.Lang}}</p> |  | ||||||
| 		</div> | 		</div> | ||||||
| 	{{else}} | 	{{else}} | ||||||
| 	<div> | 	<div> | ||||||
|  |  | ||||||
|  | @ -3,7 +3,11 @@ | ||||||
| 	<div class="ui container"> | 	<div class="ui container"> | ||||||
| 		<div class="repo-header"> | 		<div class="repo-header"> | ||||||
| 			<div class="ui huge breadcrumb repo-title"> | 			<div class="ui huge breadcrumb repo-title"> | ||||||
|  | 				{{if .RelAvatarLink}} | ||||||
|  | 				<img class="ui avatar image" src="{{.RelAvatarLink}}"> | ||||||
|  | 				{{else}} | ||||||
| 				<i class="mega-octicon octicon-{{if .IsPrivate}}lock{{else if .IsMirror}}repo-clone{{else if .IsFork}}repo-forked{{else}}repo{{end}}"></i> | 				<i class="mega-octicon octicon-{{if .IsPrivate}}lock{{else if .IsMirror}}repo-clone{{else if .IsFork}}repo-forked{{else}}repo{{end}}"></i> | ||||||
|  | 				{{end}} | ||||||
| 				<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> | ||||||
|  |  | ||||||
|  | @ -41,6 +41,22 @@ | ||||||
| 					<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> | 			</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> | 		</div> | ||||||
| 
 | 
 | ||||||
| 		{{if .Repository.IsMirror}} | 		{{if .Repository.IsMirror}} | ||||||
|  |  | ||||||
|  | @ -9066,6 +9066,10 @@ | ||||||
|           "type": "boolean", |           "type": "boolean", | ||||||
|           "x-go-name": "Archived" |           "x-go-name": "Archived" | ||||||
|         }, |         }, | ||||||
|  |         "avatar_url": { | ||||||
|  |           "type": "string", | ||||||
|  |           "x-go-name": "AvatarURL" | ||||||
|  |         }, | ||||||
|         "clone_url": { |         "clone_url": { | ||||||
|           "type": "string", |           "type": "string", | ||||||
|           "x-go-name": "CloneURL" |           "x-go-name": "CloneURL" | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue