* Only show repository avatar in list when one was selected Signed-off-by: Mario Lubenka <mario.lubenka@googlemail.com> * Adds fallback configuration option for repository avatar Signed-off-by: Mario Lubenka <mario.lubenka@googlemail.com> * Implements repository avatar fallback Signed-off-by: Mario Lubenka <mario.lubenka@googlemail.com> * Adds admin task for deleting generated repository avatars Signed-off-by: Mario Lubenka <mario.lubenka@googlemail.com> * Solve linting issues Signed-off-by: Mario Lubenka <mario.lubenka@googlemail.com> * Save avatar before updating database * Linting * Update models/repo.go Co-Authored-By: zeripath <art27@cantab.net>tags/v1.21.12.1
| @@ -505,6 +505,10 @@ SESSION_LIFE_TIME = 86400 | |||||
| [picture] | [picture] | ||||
| AVATAR_UPLOAD_PATH = data/avatars | AVATAR_UPLOAD_PATH = data/avatars | ||||
| REPOSITORY_AVATAR_UPLOAD_PATH = data/repo-avatars | REPOSITORY_AVATAR_UPLOAD_PATH = data/repo-avatars | ||||
| ; How Gitea deals with missing repository avatars | |||||
| ; none = no avatar will be displayed; random = random avatar will be displayed; image = default image will be used | |||||
| REPOSITORY_AVATAR_FALLBACK = none | |||||
| REPOSITORY_AVATAR_FALLBACK_IMAGE = /img/repo_default.png | |||||
| ; Max Width and Height of uploaded avatars. | ; Max Width and Height of uploaded avatars. | ||||
| ; This is to limit the amount of RAM used when resizing the image. | ; This is to limit the amount of RAM used when resizing the image. | ||||
| AVATAR_MAX_WIDTH = 4096 | AVATAR_MAX_WIDTH = 4096 | ||||
| @@ -292,6 +292,11 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`. | |||||
| [http://www.libravatar.org](http://www.libravatar.org)). | [http://www.libravatar.org](http://www.libravatar.org)). | ||||
| - `AVATAR_UPLOAD_PATH`: **data/avatars**: Path to store user avatar image 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. | - `REPOSITORY_AVATAR_UPLOAD_PATH`: **data/repo-avatars**: Path to store repository avatar image files. | ||||
| - `REPOSITORY_AVATAR_FALLBACK`: **none**: How Gitea deals with missing repository avatars | |||||
| - none = no avatar will be displayed | |||||
| - random = random avatar will be generated | |||||
| - image = default image will be used (which is set in `REPOSITORY_AVATAR_DEFAULT_IMAGE`) | |||||
| - `REPOSITORY_AVATAR_FALLBACK_IMAGE`: **/img/repo_default.png**: Image used as default repository avatar (if `REPOSITORY_AVATAR_FALLBACK` is set to image and none was uploaded) | |||||
| - `AVATAR_MAX_WIDTH`: **4096**: Maximum avatar image width in pixels. | - `AVATAR_MAX_WIDTH`: **4096**: Maximum avatar image width in pixels. | ||||
| - `AVATAR_MAX_HEIGHT`: **3072**: Maximum avatar image height 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. | - `AVATAR_MAX_FILE_SIZE`: **1048576** (1Mb): Maximum avatar image file size in bytes. | ||||
| @@ -2528,17 +2528,78 @@ func (repo *Repository) CustomAvatarPath() string { | |||||
| return filepath.Join(setting.RepositoryAvatarUploadPath, repo.Avatar) | 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. | |||||
| // GenerateRandomAvatar generates a random avatar for repository. | |||||
| func (repo *Repository) GenerateRandomAvatar() error { | |||||
| return repo.generateRandomAvatar(x) | |||||
| } | |||||
| func (repo *Repository) generateRandomAvatar(e Engine) error { | |||||
| idToString := fmt.Sprintf("%d", repo.ID) | |||||
| seed := idToString | |||||
| img, err := avatar.RandomImage([]byte(seed)) | |||||
| if err != nil { | |||||
| return fmt.Errorf("RandomImage: %v", err) | |||||
| } | |||||
| repo.Avatar = idToString | |||||
| if err = os.MkdirAll(filepath.Dir(repo.CustomAvatarPath()), os.ModePerm); err != nil { | |||||
| return fmt.Errorf("MkdirAll: %v", err) | |||||
| } | |||||
| fw, err := os.Create(repo.CustomAvatarPath()) | |||||
| if err != nil { | |||||
| return fmt.Errorf("Create: %v", err) | |||||
| } | |||||
| defer fw.Close() | |||||
| if err = png.Encode(fw, img); err != nil { | |||||
| return fmt.Errorf("Encode: %v", err) | |||||
| } | |||||
| log.Info("New random avatar created for repository: %d", repo.ID) | |||||
| if _, err := e.ID(repo.ID).Cols("avatar").NoAutoTime().Update(repo); err != nil { | |||||
| return err | |||||
| } | |||||
| return nil | |||||
| } | |||||
| // RemoveRandomAvatars removes the randomly generated avatars that were created for repositories | |||||
| func RemoveRandomAvatars() error { | |||||
| var ( | |||||
| err error | |||||
| ) | |||||
| err = x. | |||||
| Where("id > 0").BufferSize(setting.IterateBufferSize). | |||||
| Iterate(new(Repository), | |||||
| func(idx int, bean interface{}) error { | |||||
| repository := bean.(*Repository) | |||||
| stringifiedID := strconv.FormatInt(repository.ID, 10) | |||||
| if repository.Avatar == stringifiedID { | |||||
| return repository.DeleteAvatar() | |||||
| } | |||||
| return nil | |||||
| }) | |||||
| return err | |||||
| } | |||||
| // RelAvatarLink returns a relative link to the repository's avatar. | |||||
| func (repo *Repository) RelAvatarLink() string { | func (repo *Repository) RelAvatarLink() string { | ||||
| // If no avatar - path is empty | // If no avatar - path is empty | ||||
| avatarPath := repo.CustomAvatarPath() | avatarPath := repo.CustomAvatarPath() | ||||
| if len(avatarPath) <= 0 { | |||||
| return "" | |||||
| } | |||||
| if !com.IsFile(avatarPath) { | |||||
| return "" | |||||
| if len(avatarPath) <= 0 || !com.IsFile(avatarPath) { | |||||
| switch mode := setting.RepositoryAvatarFallback; mode { | |||||
| case "image": | |||||
| return setting.RepositoryAvatarFallbackImage | |||||
| case "random": | |||||
| if err := repo.GenerateRandomAvatar(); err != nil { | |||||
| log.Error("GenerateRandomAvatar: %v", err) | |||||
| } | |||||
| default: | |||||
| // default behaviour: do not display avatar | |||||
| return "" | |||||
| } | |||||
| } | } | ||||
| return setting.AppSubURL + "/repo-avatars/" + repo.Avatar | return setting.AppSubURL + "/repo-avatars/" + repo.Avatar | ||||
| } | } | ||||
| @@ -250,16 +250,18 @@ var ( | |||||
| } | } | ||||
| // Picture settings | // Picture settings | ||||
| AvatarUploadPath string | |||||
| AvatarMaxWidth int | |||||
| AvatarMaxHeight int | |||||
| GravatarSource string | |||||
| GravatarSourceURL *url.URL | |||||
| DisableGravatar bool | |||||
| EnableFederatedAvatar bool | |||||
| LibravatarService *libravatar.Libravatar | |||||
| AvatarMaxFileSize int64 | |||||
| RepositoryAvatarUploadPath string | |||||
| AvatarUploadPath string | |||||
| AvatarMaxWidth int | |||||
| AvatarMaxHeight int | |||||
| GravatarSource string | |||||
| GravatarSourceURL *url.URL | |||||
| DisableGravatar bool | |||||
| EnableFederatedAvatar bool | |||||
| LibravatarService *libravatar.Libravatar | |||||
| AvatarMaxFileSize int64 | |||||
| RepositoryAvatarUploadPath string | |||||
| RepositoryAvatarFallback string | |||||
| RepositoryAvatarFallbackImage string | |||||
| // Log settings | // Log settings | ||||
| LogLevel string | LogLevel string | ||||
| @@ -842,6 +844,8 @@ func NewContext() { | |||||
| if !filepath.IsAbs(RepositoryAvatarUploadPath) { | if !filepath.IsAbs(RepositoryAvatarUploadPath) { | ||||
| RepositoryAvatarUploadPath = path.Join(AppWorkPath, RepositoryAvatarUploadPath) | RepositoryAvatarUploadPath = path.Join(AppWorkPath, RepositoryAvatarUploadPath) | ||||
| } | } | ||||
| RepositoryAvatarFallback = sec.Key("REPOSITORY_AVATAR_FALLBACK").MustString("none") | |||||
| RepositoryAvatarFallbackImage = sec.Key("REPOSITORY_AVATAR_FALLBACK_IMAGE").MustString("/img/repo_default.png") | |||||
| 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) | AvatarMaxFileSize = sec.Key("AVATAR_MAX_FILE_SIZE").MustInt64(1048576) | ||||
| @@ -1522,6 +1522,8 @@ dashboard.delete_repo_archives = Delete all repository archives | |||||
| dashboard.delete_repo_archives_success = All repository archives have been deleted. | dashboard.delete_repo_archives_success = All repository archives have been deleted. | ||||
| dashboard.delete_missing_repos = Delete all repositories missing their Git files | dashboard.delete_missing_repos = Delete all repositories missing their Git files | ||||
| dashboard.delete_missing_repos_success = All repositories missing their Git files have been deleted. | dashboard.delete_missing_repos_success = All repositories missing their Git files have been deleted. | ||||
| dashboard.delete_generated_repository_avatars = Delete generated repository avatars | |||||
| dashboard.delete_generated_repository_avatars_success = Generated repository avatars were deleted. | |||||
| dashboard.git_gc_repos = Garbage collect all repositories | dashboard.git_gc_repos = Garbage collect all repositories | ||||
| dashboard.git_gc_repos_success = All repositories have finished garbage collection. | dashboard.git_gc_repos_success = All repositories have finished garbage collection. | ||||
| dashboard.resync_all_sshkeys = Update the '.ssh/authorized_keys' file with Gitea SSH keys. (Not needed for the built-in SSH server.) | dashboard.resync_all_sshkeys = Update the '.ssh/authorized_keys' file with Gitea SSH keys. (Not needed for the built-in SSH server.) | ||||
| @@ -125,6 +125,7 @@ const ( | |||||
| reinitMissingRepository | reinitMissingRepository | ||||
| syncExternalUsers | syncExternalUsers | ||||
| gitFsck | gitFsck | ||||
| deleteGeneratedRepositoryAvatars | |||||
| ) | ) | ||||
| // Dashboard show admin panel dashboard | // Dashboard show admin panel dashboard | ||||
| @@ -167,6 +168,9 @@ func Dashboard(ctx *context.Context) { | |||||
| case gitFsck: | case gitFsck: | ||||
| success = ctx.Tr("admin.dashboard.git_fsck_started") | success = ctx.Tr("admin.dashboard.git_fsck_started") | ||||
| go models.GitFsck() | go models.GitFsck() | ||||
| case deleteGeneratedRepositoryAvatars: | |||||
| success = ctx.Tr("admin.dashboard.delete_generated_repository_avatars_success") | |||||
| err = models.RemoveRandomAvatars() | |||||
| } | } | ||||
| if err != nil { | if err != nil { | ||||
| @@ -53,6 +53,10 @@ | |||||
| <td>{{.i18n.Tr "admin.dashboard.git_fsck"}}</td> | <td>{{.i18n.Tr "admin.dashboard.git_fsck"}}</td> | ||||
| <td><i class="fa fa-caret-square-o-right"></i> <a href="{{AppSubUrl}}/admin?op=9">{{.i18n.Tr "admin.dashboard.operation_run"}}</a></td> | <td><i class="fa fa-caret-square-o-right"></i> <a href="{{AppSubUrl}}/admin?op=9">{{.i18n.Tr "admin.dashboard.operation_run"}}</a></td> | ||||
| </tr> | </tr> | ||||
| <tr> | |||||
| <td>{{.i18n.Tr "admin.dashboard.delete_generated_repository_avatars"}}</td> | |||||
| <td><i class="fa fa-caret-square-o-right"></i> <a href="{{AppSubUrl}}/admin?op=10">{{.i18n.Tr "admin.dashboard.operation_run"}}</a></td> | |||||
| </tr> | |||||
| </tbody> | </tbody> | ||||
| </table> | </table> | ||||
| </div> | </div> | ||||
| @@ -2,7 +2,9 @@ | |||||
| {{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}}"> | |||||
| {{if .RelAvatarLink}} | |||||
| <img class="ui avatar image" src="{{.RelAvatarLink}}"> | |||||
| {{end}} | |||||
| <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}} | ||||