Avatar upload (#730)

Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
This commit is contained in:
Thomas
2026-06-26 00:47:59 +07:00
committed by GitHub
parent 18ae1970e7
commit 4a83e3ecda
7 changed files with 211 additions and 6 deletions
+22 -5
View File
@@ -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 {
+8 -1
View File
@@ -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(),
}
+7
View File
@@ -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
+128
View File
@@ -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
}
+4
View File
@@ -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
}
+4
View File
@@ -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)
+38
View File
@@ -1,6 +1,44 @@
{{ template "header" .}}
{{ template "settings_header" .}}
<div class="relative mx-auto max-w-160 space-y-8">
<div class="w-full">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10">
<h2 class="text-md font-bold text-slate-700 dark:text-slate-300">
{{ .locale.Tr "settings.avatar" }}
</h2>
<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
{{ .locale.Tr "settings.avatar-help" }}
</h3>
<div class="flex items-center gap-x-4">
<div class="shrink-0">
{{ if not (shouldGenerateAvatar .userLogged .DisableGravatar) }}
<img class="h-16 w-16 rounded-md border border-gray-200 dark:border-gray-700" src="{{ avatarUrl .userLogged .DisableGravatar }}" alt="{{ .userLogged.Username }}'s Avatar">
{{ else }}
<svg class="h-16 w-16 rounded-md border border-gray-200 dark:border-gray-700"
data-jdenticon-value="{{ .userLogged.Username }}"
width="64"
height="64">
</svg>
{{ end }}
</div>
<div class="flex-auto">
<form class="flex flex-wrap items-center gap-3" action="{{ $.c.ExternalUrl }}/settings/avatar" method="post" enctype="multipart/form-data">
<input id="avatar" name="avatar" type="file" accept="image/png,image/jpeg,image/gif,image/webp" required onchange="this.form.submit()" class="block text-sm text-slate-700 dark:text-slate-300 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-500 file:text-white hover:file:bg-primary-600 file:cursor-pointer">
{{ .csrfHtml }}
</form>
{{ if .userLogged.HasUploadedAvatar }}
<form class="mt-3" action="{{ $.c.ExternalUrl }}/settings/avatar" method="post">
<input type="hidden" name="_method" value="DELETE">
<button type="submit" class="inline-flex items-center px-4 py-2 border border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-slate-700 dark:text-slate-300 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">
{{ .locale.Tr "settings.avatar-remove" }}
</button>
{{ .csrfHtml }}
</form>
{{ end }}
</div>
</div>
</div>
</div>
<div class="sm:grid {{ if not .disableForm }}grid-cols-2{{ else }}grid-cols-1{{ end }} gap-x-4 md:gap-x-8 space-y-8 md:space-y-0">
<div class="w-full">
<div class="bg-white dark:bg-gray-900 rounded-md border border-1 border-gray-200 dark:border-gray-700 py-8 px-4 shadow sm:rounded-lg sm:px-10 h-full">