Skip to content

Commit e524e1a

Browse files
sergey-dryabzhinskyjeffliu27
authored andcommitted
Repository avatars (go-gitea#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
1 parent 595ce71 commit e524e1a

File tree

19 files changed

+355
-20
lines changed

19 files changed

+355
-20
lines changed

custom/conf/app.ini.sample

+6-2
Original file line numberDiff line numberDiff line change
@@ -504,10 +504,14 @@ SESSION_LIFE_TIME = 86400
504504

505505
[picture]
506506
AVATAR_UPLOAD_PATH = data/avatars
507-
; Max Width and Height of uploaded avatars. This is to limit the amount of RAM
508-
; used when resizing the image.
507+
REPOSITORY_AVATAR_UPLOAD_PATH = data/repo-avatars
508+
; Max Width and Height of uploaded avatars.
509+
; This is to limit the amount of RAM used when resizing the image.
509510
AVATAR_MAX_WIDTH = 4096
510511
AVATAR_MAX_HEIGHT = 3072
512+
; Maximum alloved file size for uploaded avatars.
513+
; This is to limit the amount of RAM used when resizing the image.
514+
AVATAR_MAX_FILE_SIZE = 1048576
511515
; Chinese users can choose "duoshuo"
512516
; or a custom avatar source, like: http://cn.gravatar.com/avatar/
513517
GRAVATAR_SOURCE = gravatar

docker/root/etc/templates/app.ini

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ PROVIDER_CONFIG = /data/gitea/sessions
3535

3636
[picture]
3737
AVATAR_UPLOAD_PATH = /data/gitea/avatars
38+
REPOSITORY_AVATAR_UPLOAD_PATH = /data/gitea/repo-avatars
3839

3940
[attachment]
4041
PATH = /data/gitea/attachments

docs/content/doc/advanced/config-cheat-sheet.en-us.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,11 @@ Values containing `#` or `;` must be quoted using `` ` `` or `"""`.
290290
- `DISABLE_GRAVATAR`: **false**: Enable this to use local avatars only.
291291
- `ENABLE_FEDERATED_AVATAR`: **false**: Enable support for federated avatars (see
292292
[http://www.libravatar.org](http://www.libravatar.org)).
293-
- `AVATAR_UPLOAD_PATH`: **data/avatars**: Path to store local and cached files.
293+
- `AVATAR_UPLOAD_PATH`: **data/avatars**: Path to store user avatar image files.
294+
- `REPOSITORY_AVATAR_UPLOAD_PATH`: **data/repo-avatars**: Path to store repository avatar image files.
295+
- `AVATAR_MAX_WIDTH`: **4096**: Maximum avatar image width in pixels.
296+
- `AVATAR_MAX_HEIGHT`: **3072**: Maximum avatar image height in pixels.
297+
- `AVATAR_MAX_FILE_SIZE`: **1048576** (1Mb): Maximum avatar image file size in bytes.
294298

295299
## Attachment (`attachment`)
296300

models/migrations/migrations.go

+2
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ var migrations = []Migration{
227227
NewMigration("hash application token", hashAppToken),
228228
// v86 -> v87
229229
NewMigration("add http method to webhook", addHTTPMethodToWebhook),
230+
// v87 -> v88
231+
NewMigration("add avatar field to repository", addAvatarFieldToRepository),
230232
}
231233

232234
// Migrate database to current version

models/migrations/v87.go

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright 2019 Gitea. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package migrations
6+
7+
import (
8+
"github.com/go-xorm/xorm"
9+
)
10+
11+
func addAvatarFieldToRepository(x *xorm.Engine) error {
12+
type Repository struct {
13+
// ID(10-20)-md5(32) - must fit into 64 symbols
14+
Avatar string `xorm:"VARCHAR(64)"`
15+
}
16+
17+
return x.Sync2(new(Repository))
18+
}

models/repo.go

+135
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@ package models
77

88
import (
99
"bytes"
10+
"crypto/md5"
1011
"errors"
1112
"fmt"
1213
"html/template"
14+
15+
// Needed for jpeg support
16+
_ "image/jpeg"
17+
"image/png"
1318
"io/ioutil"
1419
"net/url"
1520
"os"
@@ -21,6 +26,7 @@ import (
2126
"strings"
2227
"time"
2328

29+
"code.gitea.io/gitea/modules/avatar"
2430
"code.gitea.io/gitea/modules/git"
2531
"code.gitea.io/gitea/modules/log"
2632
"code.gitea.io/gitea/modules/markup"
@@ -166,6 +172,9 @@ type Repository struct {
166172
CloseIssuesViaCommitInAnyBranch bool `xorm:"NOT NULL DEFAULT false"`
167173
Topics []string `xorm:"TEXT JSON"`
168174

175+
// Avatar: ID(10-20)-md5(32) - must fit into 64 symbols
176+
Avatar string `xorm:"VARCHAR(64)"`
177+
169178
CreatedUnix util.TimeStamp `xorm:"INDEX created"`
170179
UpdatedUnix util.TimeStamp `xorm:"INDEX updated"`
171180
}
@@ -290,6 +299,7 @@ func (repo *Repository) innerAPIFormat(e Engine, mode AccessMode, isParent bool)
290299
Created: repo.CreatedUnix.AsTime(),
291300
Updated: repo.UpdatedUnix.AsTime(),
292301
Permissions: permission,
302+
AvatarURL: repo.AvatarLink(),
293303
}
294304
}
295305

@@ -1865,6 +1875,16 @@ func DeleteRepository(doer *User, uid, repoID int64) error {
18651875
go HookQueue.Add(repo.ID)
18661876
}
18671877

1878+
if len(repo.Avatar) > 0 {
1879+
avatarPath := repo.CustomAvatarPath()
1880+
if com.IsExist(avatarPath) {
1881+
if err := os.Remove(avatarPath); err != nil {
1882+
return fmt.Errorf("Failed to remove %s: %v", avatarPath, err)
1883+
}
1884+
}
1885+
}
1886+
1887+
DeleteRepoFromIndexer(repo)
18681888
return nil
18691889
}
18701890

@@ -2447,3 +2467,118 @@ func (repo *Repository) GetUserFork(userID int64) (*Repository, error) {
24472467
}
24482468
return &forkedRepo, nil
24492469
}
2470+
2471+
// CustomAvatarPath returns repository custom avatar file path.
2472+
func (repo *Repository) CustomAvatarPath() string {
2473+
// Avatar empty by default
2474+
if len(repo.Avatar) <= 0 {
2475+
return ""
2476+
}
2477+
return filepath.Join(setting.RepositoryAvatarUploadPath, repo.Avatar)
2478+
}
2479+
2480+
// RelAvatarLink returns a relative link to the user's avatar.
2481+
// The link a sub-URL to this site
2482+
// Since Gravatar support not needed here - just check for image path.
2483+
func (repo *Repository) RelAvatarLink() string {
2484+
// If no avatar - path is empty
2485+
avatarPath := repo.CustomAvatarPath()
2486+
if len(avatarPath) <= 0 {
2487+
return ""
2488+
}
2489+
if !com.IsFile(avatarPath) {
2490+
return ""
2491+
}
2492+
return setting.AppSubURL + "/repo-avatars/" + repo.Avatar
2493+
}
2494+
2495+
// AvatarLink returns user avatar absolute link.
2496+
func (repo *Repository) AvatarLink() string {
2497+
link := repo.RelAvatarLink()
2498+
// link may be empty!
2499+
if len(link) > 0 {
2500+
if link[0] == '/' && link[1] != '/' {
2501+
return setting.AppURL + strings.TrimPrefix(link, setting.AppSubURL)[1:]
2502+
}
2503+
}
2504+
return link
2505+
}
2506+
2507+
// UploadAvatar saves custom avatar for repository.
2508+
// FIXME: split uploads to different subdirs in case we have massive number of repos.
2509+
func (repo *Repository) UploadAvatar(data []byte) error {
2510+
m, err := avatar.Prepare(data)
2511+
if err != nil {
2512+
return err
2513+
}
2514+
2515+
sess := x.NewSession()
2516+
defer sess.Close()
2517+
if err = sess.Begin(); err != nil {
2518+
return err
2519+
}
2520+
2521+
oldAvatarPath := repo.CustomAvatarPath()
2522+
2523+
// Users can upload the same image to other repo - prefix it with ID
2524+
// Then repo will be removed - only it avatar file will be removed
2525+
repo.Avatar = fmt.Sprintf("%d-%x", repo.ID, md5.Sum(data))
2526+
if _, err := sess.ID(repo.ID).Cols("avatar").Update(repo); err != nil {
2527+
return fmt.Errorf("UploadAvatar: Update repository avatar: %v", err)
2528+
}
2529+
2530+
if err := os.MkdirAll(setting.RepositoryAvatarUploadPath, os.ModePerm); err != nil {
2531+
return fmt.Errorf("UploadAvatar: Failed to create dir %s: %v", setting.RepositoryAvatarUploadPath, err)
2532+
}
2533+
2534+
fw, err := os.Create(repo.CustomAvatarPath())
2535+
if err != nil {
2536+
return fmt.Errorf("UploadAvatar: Create file: %v", err)
2537+
}
2538+
defer fw.Close()
2539+
2540+
if err = png.Encode(fw, *m); err != nil {
2541+
return fmt.Errorf("UploadAvatar: Encode png: %v", err)
2542+
}
2543+
2544+
if len(oldAvatarPath) > 0 && oldAvatarPath != repo.CustomAvatarPath() {
2545+
if err := os.Remove(oldAvatarPath); err != nil {
2546+
return fmt.Errorf("UploadAvatar: Failed to remove old repo avatar %s: %v", oldAvatarPath, err)
2547+
}
2548+
}
2549+
2550+
return sess.Commit()
2551+
}
2552+
2553+
// DeleteAvatar deletes the repos's custom avatar.
2554+
func (repo *Repository) DeleteAvatar() error {
2555+
2556+
// Avatar not exists
2557+
if len(repo.Avatar) == 0 {
2558+
return nil
2559+
}
2560+
2561+
avatarPath := repo.CustomAvatarPath()
2562+
log.Trace("DeleteAvatar[%d]: %s", repo.ID, avatarPath)
2563+
2564+
sess := x.NewSession()
2565+
defer sess.Close()
2566+
if err := sess.Begin(); err != nil {
2567+
return err
2568+
}
2569+
2570+
repo.Avatar = ""
2571+
if _, err := sess.ID(repo.ID).Cols("avatar").Update(repo); err != nil {
2572+
return fmt.Errorf("DeleteAvatar: Update repository avatar: %v", err)
2573+
}
2574+
2575+
if _, err := os.Stat(avatarPath); err == nil {
2576+
if err := os.Remove(avatarPath); err != nil {
2577+
return fmt.Errorf("DeleteAvatar: Failed to remove %s: %v", avatarPath, err)
2578+
}
2579+
} else {
2580+
// // Schrodinger: file may or may not exist. See err for details.
2581+
log.Trace("DeleteAvatar[%d]: %v", err)
2582+
}
2583+
return sess.Commit()
2584+
}

models/repo_test.go

+53
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
package models
66

77
import (
8+
"bytes"
9+
"crypto/md5"
10+
"fmt"
11+
"image"
12+
"image/png"
813
"testing"
914

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

159164
CheckConsistencyFor(t, &Repository{}, &User{}, &Team{})
160165
}
166+
167+
func TestUploadAvatar(t *testing.T) {
168+
169+
// Generate image
170+
myImage := image.NewRGBA(image.Rect(0, 0, 1, 1))
171+
var buff bytes.Buffer
172+
png.Encode(&buff, myImage)
173+
174+
assert.NoError(t, PrepareTestDatabase())
175+
repo := AssertExistsAndLoadBean(t, &Repository{ID: 10}).(*Repository)
176+
177+
err := repo.UploadAvatar(buff.Bytes())
178+
assert.NoError(t, err)
179+
assert.Equal(t, fmt.Sprintf("%d-%x", 10, md5.Sum(buff.Bytes())), repo.Avatar)
180+
}
181+
182+
func TestUploadBigAvatar(t *testing.T) {
183+
184+
// Generate BIG image
185+
myImage := image.NewRGBA(image.Rect(0, 0, 5000, 1))
186+
var buff bytes.Buffer
187+
png.Encode(&buff, myImage)
188+
189+
assert.NoError(t, PrepareTestDatabase())
190+
repo := AssertExistsAndLoadBean(t, &Repository{ID: 10}).(*Repository)
191+
192+
err := repo.UploadAvatar(buff.Bytes())
193+
assert.Error(t, err)
194+
}
195+
196+
func TestDeleteAvatar(t *testing.T) {
197+
198+
// Generate image
199+
myImage := image.NewRGBA(image.Rect(0, 0, 1, 1))
200+
var buff bytes.Buffer
201+
png.Encode(&buff, myImage)
202+
203+
assert.NoError(t, PrepareTestDatabase())
204+
repo := AssertExistsAndLoadBean(t, &Repository{ID: 10}).(*Repository)
205+
206+
err := repo.UploadAvatar(buff.Bytes())
207+
assert.NoError(t, err)
208+
209+
err = repo.DeleteAvatar()
210+
assert.NoError(t, err)
211+
212+
assert.Equal(t, "", repo.Avatar)
213+
}

modules/setting/setting.go

+16-8
Original file line numberDiff line numberDiff line change
@@ -250,14 +250,16 @@ var (
250250
}
251251

252252
// Picture settings
253-
AvatarUploadPath string
254-
AvatarMaxWidth int
255-
AvatarMaxHeight int
256-
GravatarSource string
257-
GravatarSourceURL *url.URL
258-
DisableGravatar bool
259-
EnableFederatedAvatar bool
260-
LibravatarService *libravatar.Libravatar
253+
AvatarUploadPath string
254+
AvatarMaxWidth int
255+
AvatarMaxHeight int
256+
GravatarSource string
257+
GravatarSourceURL *url.URL
258+
DisableGravatar bool
259+
EnableFederatedAvatar bool
260+
LibravatarService *libravatar.Libravatar
261+
AvatarMaxFileSize int64
262+
RepositoryAvatarUploadPath string
261263

262264
// Log settings
263265
LogLevel string
@@ -835,8 +837,14 @@ func NewContext() {
835837
if !filepath.IsAbs(AvatarUploadPath) {
836838
AvatarUploadPath = path.Join(AppWorkPath, AvatarUploadPath)
837839
}
840+
RepositoryAvatarUploadPath = sec.Key("REPOSITORY_AVATAR_UPLOAD_PATH").MustString(path.Join(AppDataPath, "repo-avatars"))
841+
forcePathSeparator(RepositoryAvatarUploadPath)
842+
if !filepath.IsAbs(RepositoryAvatarUploadPath) {
843+
RepositoryAvatarUploadPath = path.Join(AppWorkPath, RepositoryAvatarUploadPath)
844+
}
838845
AvatarMaxWidth = sec.Key("AVATAR_MAX_WIDTH").MustInt(4096)
839846
AvatarMaxHeight = sec.Key("AVATAR_MAX_HEIGHT").MustInt(3072)
847+
AvatarMaxFileSize = sec.Key("AVATAR_MAX_FILE_SIZE").MustInt64(1048576)
840848
switch source := sec.Key("GRAVATAR_SOURCE").MustString("gravatar"); source {
841849
case "duoshuo":
842850
GravatarSource = "http://gravatar.duoshuo.com/avatar/"

modules/structs/repo.go

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type Repository struct {
4343
// swagger:strfmt date-time
4444
Updated time.Time `json:"updated_at"`
4545
Permissions *Permission `json:"permissions,omitempty"`
46+
AvatarURL string `json:"avatar_url"`
4647
}
4748

4849
// CreateRepoOption options when creating repository

options/locale/locale_en-US.ini

+2
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ choose_new_avatar = Choose new avatar
389389
update_avatar = Update Avatar
390390
delete_current_avatar = Delete Current Avatar
391391
uploaded_avatar_not_a_image = The uploaded file is not an image.
392+
uploaded_avatar_is_too_big = The uploaded file has exceeded the maximum size.
392393
update_avatar_success = Your avatar has been updated.
393394

394395
change_password = Update Password
@@ -1314,6 +1315,7 @@ settings.unarchive.header = Un-Archive This Repo
13141315
settings.unarchive.text = Un-Archiving the repo will restore its ability to recieve commits and pushes, as well as new issues and pull-requests.
13151316
settings.unarchive.success = The repo was successfully un-archived.
13161317
settings.unarchive.error = An error occured while trying to un-archive the repo. See the log for more details.
1318+
settings.update_avatar_success = The repository avatar has been updated.
13171319
13181320
diff.browse_source = Browse Source
13191321
diff.parent = parent

public/css/index.css

+1
Original file line numberDiff line numberDiff line change
@@ -956,6 +956,7 @@ tbody.commit-list{vertical-align:baseline}
956956
.ui.repository.list .item .ui.header .metas span:not(:last-child){margin-right:5px}
957957
.ui.repository.list .item .time{font-size:12px;color:grey}
958958
.ui.repository.list .item .ui.tags{margin-bottom:1em}
959+
.ui.repository.list .item .ui.avatar.image{width:24px;height:24px}
959960
.ui.repository.branches .time{font-size:12px;color:grey}
960961
.ui.user.list .item{padding-bottom:25px}
961962
.ui.user.list .item:not(:first-child){border-top:1px solid #eee;padding-top:25px}

public/less/_explore.less

+5
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@
5353
.ui.tags {
5454
margin-bottom: 1em;
5555
}
56+
57+
.ui.avatar.image {
58+
width: 24px;
59+
height: 24px;
60+
}
5661
}
5762
}
5863

0 commit comments

Comments
 (0)