mirror of
https://github.com/thomiceli/opengist.git
synced 2026-08-07 07:14:49 +00:00
Avatar upload (#730)
Signed-off-by: Thomas Miceli <tho.miceli@gmail.com>
This commit is contained in:
+22
-5
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user