From 4a83e3ecda55e8c9a061d7189aee8a56e02f2c18 Mon Sep 17 00:00:00 2001 From: Thomas <27960254+thomiceli@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:47:59 +0700 Subject: [PATCH] Avatar upload (#730) Signed-off-by: Thomas Miceli --- internal/db/user.go | 27 ++++- internal/db/user_api.go | 9 +- internal/i18n/locales/en-US.yml | 7 ++ internal/web/handlers/settings/avatar.go | 128 +++++++++++++++++++++++ internal/web/server/renderer.go | 4 + internal/web/server/router.go | 4 + templates/pages/settings_account.html | 38 +++++++ 7 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 internal/web/handlers/settings/avatar.go diff --git a/internal/db/user.go b/internal/db/user.go index d12bccf..beb37ce 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -2,8 +2,11 @@ package db import ( "encoding/json" + "os" + "path/filepath" "strings" + "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/git" "gorm.io/gorm" ) @@ -17,7 +20,7 @@ type User struct { CreatedAt int64 Email string MD5Hash string // for gravatar, if no Email is specified, the value is random - AvatarURL string + AvatarURL string // an absolute URL (from an OAuth provider) or a bare filename for a manually uploaded avatar stored under {home}/avatars GithubID string GitlabID string GiteaID string @@ -96,6 +99,11 @@ func (user *User) BeforeDelete(tx *gorm.DB) error { return err } + // Delete uploaded avatar, if any + if user.HasUploadedAvatar() { + _ = os.Remove(filepath.Join(config.GetHomeDir(), "avatars", filepath.Base(user.AvatarURL))) + } + return nil } @@ -228,10 +236,13 @@ func (user *User) DeleteProviderID(provider string) error { } if providerIDField, ok := providerIDFields[provider]; ok { - return db.Model(&user). - Update(providerIDField, nil). - Update("avatar_url", nil). - Error + query := db.Model(&user).Update(providerIDField, nil) + // Only clear the avatar when it came from the provider, not when the + // user has uploaded their own. + if !user.HasUploadedAvatar() { + query = query.Update("avatar_url", nil) + } + return query.Error } return nil @@ -259,6 +270,12 @@ func (user *User) GetStyle() *UserStyleDTO { return style } +// HasUploadedAvatar reports whether the user's AvatarURL refers to a manually +// uploaded avatar (a bare filename) rather than an absolute OAuth provider URL. +func (user *User) HasUploadedAvatar() bool { + return user.AvatarURL != "" && !strings.Contains(user.AvatarURL, "://") +} + // -- DTO -- // type UserDTO struct { diff --git a/internal/db/user_api.go b/internal/db/user_api.go index 68513cb..2c8505f 100644 --- a/internal/db/user_api.go +++ b/internal/db/user_api.go @@ -1,8 +1,10 @@ package db import ( + "fmt" "time" + "github.com/thomiceli/opengist/internal/config" "github.com/thomiceli/opengist/internal/web/handlers/api/v1/types" ) @@ -12,11 +14,16 @@ import ( // (followers, repos, ...) are still populated with the spec-shaped URLs so // clients can parse cleanly. func (u *User) ToSimpleAPI() types.SimpleUser { + avatarURL := u.AvatarURL + if u.HasUploadedAvatar() { + avatarURL = fmt.Sprintf("%s/avatar/%s", config.C.ExternalUrl, u.AvatarURL) + } + return types.SimpleUser{ ID: u.ID, Login: u.Username, Username: u.Username, - AvatarURL: u.AvatarURL, + AvatarURL: avatarURL, Type: "User", CreatedAt: time.Unix(u.CreatedAt, 0).UTC(), } diff --git a/internal/i18n/locales/en-US.yml b/internal/i18n/locales/en-US.yml index e4568a4..c60431e 100644 --- a/internal/i18n/locales/en-US.yml +++ b/internal/i18n/locales/en-US.yml @@ -138,6 +138,9 @@ gist.revision.no-revisions: No revisions to show gist.revision-of: Revision of %s settings: Settings +settings.avatar: Avatar +settings.avatar-help: Upload a custom avatar to override Gravatar +settings.avatar-remove: Remove avatar settings.email: Email settings.email-help: Used for commits and Gravatar settings.email-set: Set email @@ -378,6 +381,10 @@ flash.gist.fork-own-gist: Unable to fork own gists flash.gist.forked: Gist has been forked flash.user.email-updated: Email updated +flash.user.avatar-updated: Avatar updated +flash.user.avatar-deleted: Avatar removed +flash.user.avatar-invalid: Invalid image file, allowed formats are PNG, JPEG, GIF and WebP +flash.user.avatar-too-large: Image file is too large, the maximum size is 5 MB flash.user.invalid-ssh-key: Invalid SSH key flash.user.ssh-key-added: SSH key added flash.user.ssh-key-deleted: SSH key deleted diff --git a/internal/web/handlers/settings/avatar.go b/internal/web/handlers/settings/avatar.go new file mode 100644 index 0000000..15e92fa --- /dev/null +++ b/internal/web/handlers/settings/avatar.go @@ -0,0 +1,128 @@ +package settings + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/thomiceli/opengist/internal/config" + "github.com/thomiceli/opengist/internal/db" + "github.com/thomiceli/opengist/internal/web/context" +) + +const maxAvatarSize = 5 << 20 // 5 MiB + +var allowedAvatarTypes = map[string]string{ + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", +} + +func AvatarsDir() string { + return filepath.Join(config.GetHomeDir(), "avatars") +} + +func AvatarProcess(ctx *context.Context) error { + user := ctx.User + + header, err := ctx.FormFile("avatar") + if err != nil { + ctx.AddFlash(ctx.Tr("flash.user.avatar-invalid"), "error") + return ctx.RedirectTo("/settings") + } + + if header.Size > maxAvatarSize { + ctx.AddFlash(ctx.Tr("flash.user.avatar-too-large"), "error") + return ctx.RedirectTo("/settings") + } + + src, err := header.Open() + if err != nil { + return ctx.ErrorRes(500, "Cannot open uploaded avatar", err) + } + defer src.Close() + + // Detect the content type from the file content rather than trusting the + // client-provided header, then map it to an allowed extension. + sniff := make([]byte, 512) + n, _ := io.ReadFull(src, sniff) + contentType := detectImageType(sniff[:n]) + ext, ok := allowedAvatarTypes[contentType] + if !ok { + ctx.AddFlash(ctx.Tr("flash.user.avatar-invalid"), "error") + return ctx.RedirectTo("/settings") + } + + if _, err = src.Seek(0, io.SeekStart); err != nil { + return ctx.ErrorRes(500, "Cannot read uploaded avatar", err) + } + + if err = os.MkdirAll(AvatarsDir(), 0755); err != nil { + return ctx.ErrorRes(500, "Cannot create avatars directory", err) + } + + removeAvatarFile(user) + + filename := fmt.Sprintf("%d%s", user.ID, ext) + dstPath := filepath.Join(AvatarsDir(), filename) + dst, err := os.Create(dstPath) + if err != nil { + return ctx.ErrorRes(500, "Cannot save uploaded avatar", err) + } + defer dst.Close() + + if _, err = io.Copy(dst, src); err != nil { + return ctx.ErrorRes(500, "Cannot save uploaded avatar", err) + } + + user.AvatarURL = filename + if err = user.Update(); err != nil { + return ctx.ErrorRes(500, "Cannot update avatar", err) + } + + ctx.AddFlash(ctx.Tr("flash.user.avatar-updated"), "success") + return ctx.RedirectTo("/settings") +} + +func AvatarDelete(ctx *context.Context) error { + user := ctx.User + + if !user.HasUploadedAvatar() { + return ctx.RedirectTo("/settings") + } + + removeAvatarFile(user) + + user.AvatarURL = "" + if err := user.Update(); err != nil { + return ctx.ErrorRes(500, "Cannot delete avatar", err) + } + + ctx.AddFlash(ctx.Tr("flash.user.avatar-deleted"), "success") + return ctx.RedirectTo("/settings") +} + +func removeAvatarFile(user *db.User) { + if !user.HasUploadedAvatar() { + return + } + _ = os.Remove(filepath.Join(AvatarsDir(), filepath.Base(user.AvatarURL))) +} + +// detectImageType returns the image content type for the given header bytes, +// including webp which Go's http.DetectContentType does not recognize. +func detectImageType(data []byte) string { + if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" { + return "image/webp" + } + ct := http.DetectContentType(data) + // http.DetectContentType may append parameters, keep only the media type. + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = ct[:i] + } + return ct +} diff --git a/internal/web/server/renderer.go b/internal/web/server/renderer.go index 5d77381..1faecac 100644 --- a/internal/web/server/renderer.go +++ b/internal/web/server/renderer.go @@ -70,6 +70,10 @@ func (s *Server) setFuncMap() { return strings.Trim(re.ReplaceAllString(strings.ToLower(s), "-"), "-") }, "avatarUrl": func(user *db.User, noGravatar bool) string { + if user.HasUploadedAvatar() { + return fmt.Sprintf("%s/avatar/%s", config.C.ExternalUrl, user.AvatarURL) + } + if user.AvatarURL != "" { return user.AvatarURL } diff --git a/internal/web/server/router.go b/internal/web/server/router.go index a6b5254..8c4ccfb 100644 --- a/internal/web/server/router.go +++ b/internal/web/server/router.go @@ -36,6 +36,8 @@ func (s *Server) registerRoutes() { r.GET("/healthcheck", health.Healthcheck) + r.Static("/avatar", settings.AvatarsDir()) + r.GET("/register", auth.Register) r.POST("/register", auth.ProcessRegister) r.GET("/login", auth.Login) @@ -64,6 +66,8 @@ func (s *Server) registerRoutes() { sA.GET("/style", settings.UserStyle) sA.POST("/style", settings.ProcessUserStyle) sA.POST("/email", settings.EmailProcess) + sA.POST("/avatar", settings.AvatarProcess) + sA.DELETE("/avatar", settings.AvatarDelete) sA.DELETE("/account", settings.AccountDeleteProcess) sA.POST("/ssh-keys", settings.SshKeysProcess) sA.DELETE("/ssh-keys/:id", settings.SshKeysDelete) diff --git a/templates/pages/settings_account.html b/templates/pages/settings_account.html index 3ae0a97..63f710e 100644 --- a/templates/pages/settings_account.html +++ b/templates/pages/settings_account.html @@ -1,6 +1,44 @@ {{ template "header" .}} {{ template "settings_header" .}}
+
+
+

+ {{ .locale.Tr "settings.avatar" }} +

+

+ {{ .locale.Tr "settings.avatar-help" }} +

+
+
+ {{ if not (shouldGenerateAvatar .userLogged .DisableGravatar) }} + {{ .userLogged.Username }}'s Avatar + {{ else }} + + + {{ end }} +
+
+
+ + {{ .csrfHtml }} +
+ {{ if .userLogged.HasUploadedAvatar }} +
+ + + {{ .csrfHtml }} +
+ {{ end }} +
+
+
+