From 8eba27c79257c6bc68cefbdffbb36d3596e6d3ee Mon Sep 17 00:00:00 2001
From: Mario Lubenka <mario.lubenka@googlemail.com>
Date: Sun, 2 Jun 2019 08:40:12 +0200
Subject: [PATCH] Repository avatar fallback configuration (#7087)

* 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>
---
 custom/conf/app.ini.sample                    |   4 +
 .../doc/advanced/config-cheat-sheet.en-us.md  |   5 ++
 models/repo.go                                |  77 ++++++++++++++++--
 modules/setting/setting.go                    |  24 +++---
 options/locale/locale_en-US.ini               |   2 +
 public/img/repo_default.png                   | Bin 0 -> 2464 bytes
 routers/admin/admin.go                        |   4 +
 templates/admin/dashboard.tmpl                |   4 +
 templates/explore/repo_list.tmpl              |   4 +-
 9 files changed, 105 insertions(+), 19 deletions(-)
 create mode 100644 public/img/repo_default.png

diff --git a/custom/conf/app.ini.sample b/custom/conf/app.ini.sample
index e8e3ffada68..a674984a258 100644
--- a/custom/conf/app.ini.sample
+++ b/custom/conf/app.ini.sample
@@ -505,6 +505,10 @@ SESSION_LIFE_TIME = 86400
 [picture]
 AVATAR_UPLOAD_PATH = data/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.
 ; This is to limit the amount of RAM used when resizing the image.
 AVATAR_MAX_WIDTH = 4096
diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
index 052ced6e2a9..ecc196c86e1 100644
--- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md
+++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md
@@ -292,6 +292,11 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
    [http://www.libravatar.org](http://www.libravatar.org)).
 - `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_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_HEIGHT`: **3072**: Maximum avatar image height in pixels.
 - `AVATAR_MAX_FILE_SIZE`: **1048576** (1Mb): Maximum avatar image file size in bytes.
diff --git a/models/repo.go b/models/repo.go
index 16684bdeef6..d5eca3d2250 100644
--- a/models/repo.go
+++ b/models/repo.go
@@ -2528,17 +2528,78 @@ func (repo *Repository) CustomAvatarPath() string {
 	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 {
+
 	// If no avatar - path is empty
 	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
 }
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index 9e961057882..ff53e9a3757 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -250,16 +250,18 @@ var (
 	}
 
 	// 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
 	LogLevel           string
@@ -842,6 +844,8 @@ func NewContext() {
 	if !filepath.IsAbs(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)
 	AvatarMaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(3072)
 	AvatarMaxFileSize = sec.Key("AVATAR_MAX_FILE_SIZE").MustInt64(1048576)
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 645c9770a48..ebc6ca31ce3 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -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_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_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_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.)
diff --git a/public/img/repo_default.png b/public/img/repo_default.png
new file mode 100644
index 0000000000000000000000000000000000000000..dbfa8437235208c9c565581c3ea54c38f0a70d43
GIT binary patch
literal 2464
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

literal 0
HcmV?d00001

diff --git a/routers/admin/admin.go b/routers/admin/admin.go
index 0e6fa2c2426..5107e18b7d9 100644
--- a/routers/admin/admin.go
+++ b/routers/admin/admin.go
@@ -125,6 +125,7 @@ const (
 	reinitMissingRepository
 	syncExternalUsers
 	gitFsck
+	deleteGeneratedRepositoryAvatars
 )
 
 // Dashboard show admin panel dashboard
@@ -167,6 +168,9 @@ func Dashboard(ctx *context.Context) {
 		case gitFsck:
 			success = ctx.Tr("admin.dashboard.git_fsck_started")
 			go models.GitFsck()
+		case deleteGeneratedRepositoryAvatars:
+			success = ctx.Tr("admin.dashboard.delete_generated_repository_avatars_success")
+			err = models.RemoveRandomAvatars()
 		}
 
 		if err != nil {
diff --git a/templates/admin/dashboard.tmpl b/templates/admin/dashboard.tmpl
index 13c06334a5e..262db04b906 100644
--- a/templates/admin/dashboard.tmpl
+++ b/templates/admin/dashboard.tmpl
@@ -53,6 +53,10 @@
 						<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>
 					</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>
 			</table>
 		</div>
diff --git a/templates/explore/repo_list.tmpl b/templates/explore/repo_list.tmpl
index 34aab6477a9..8c7ba51a540 100644
--- a/templates/explore/repo_list.tmpl
+++ b/templates/explore/repo_list.tmpl
@@ -2,7 +2,9 @@
 	{{range .Repos}}
 		<div class="item">
 			<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}}">
 					{{if or $.PageIsExplore $.PageIsProfileStarList }}{{if .Owner}}{{.Owner.Name}} / {{end}}{{end}}{{.Name}}
 					{{if .IsArchived}}<i class="archive icon archived-icon"></i>{{end}}