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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/thomiceli/opengist/internal/config"
|
||||||
"github.com/thomiceli/opengist/internal/git"
|
"github.com/thomiceli/opengist/internal/git"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -17,7 +20,7 @@ type User struct {
|
|||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
Email string
|
Email string
|
||||||
MD5Hash string // for gravatar, if no Email is specified, the value is random
|
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
|
GithubID string
|
||||||
GitlabID string
|
GitlabID string
|
||||||
GiteaID string
|
GiteaID string
|
||||||
@@ -96,6 +99,11 @@ func (user *User) BeforeDelete(tx *gorm.DB) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete uploaded avatar, if any
|
||||||
|
if user.HasUploadedAvatar() {
|
||||||
|
_ = os.Remove(filepath.Join(config.GetHomeDir(), "avatars", filepath.Base(user.AvatarURL)))
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,10 +236,13 @@ func (user *User) DeleteProviderID(provider string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if providerIDField, ok := providerIDFields[provider]; ok {
|
if providerIDField, ok := providerIDFields[provider]; ok {
|
||||||
return db.Model(&user).
|
query := db.Model(&user).Update(providerIDField, nil)
|
||||||
Update(providerIDField, nil).
|
// Only clear the avatar when it came from the provider, not when the
|
||||||
Update("avatar_url", nil).
|
// user has uploaded their own.
|
||||||
Error
|
if !user.HasUploadedAvatar() {
|
||||||
|
query = query.Update("avatar_url", nil)
|
||||||
|
}
|
||||||
|
return query.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -259,6 +270,12 @@ func (user *User) GetStyle() *UserStyleDTO {
|
|||||||
return style
|
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 -- //
|
// -- DTO -- //
|
||||||
|
|
||||||
type UserDTO struct {
|
type UserDTO struct {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/thomiceli/opengist/internal/config"
|
||||||
"github.com/thomiceli/opengist/internal/web/handlers/api/v1/types"
|
"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
|
// (followers, repos, ...) are still populated with the spec-shaped URLs so
|
||||||
// clients can parse cleanly.
|
// clients can parse cleanly.
|
||||||
func (u *User) ToSimpleAPI() types.SimpleUser {
|
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{
|
return types.SimpleUser{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Login: u.Username,
|
Login: u.Username,
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
AvatarURL: u.AvatarURL,
|
AvatarURL: avatarURL,
|
||||||
Type: "User",
|
Type: "User",
|
||||||
CreatedAt: time.Unix(u.CreatedAt, 0).UTC(),
|
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
|
gist.revision-of: Revision of %s
|
||||||
|
|
||||||
settings: Settings
|
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: Email
|
||||||
settings.email-help: Used for commits and Gravatar
|
settings.email-help: Used for commits and Gravatar
|
||||||
settings.email-set: Set email
|
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.gist.forked: Gist has been forked
|
||||||
|
|
||||||
flash.user.email-updated: Email updated
|
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.invalid-ssh-key: Invalid SSH key
|
||||||
flash.user.ssh-key-added: SSH key added
|
flash.user.ssh-key-added: SSH key added
|
||||||
flash.user.ssh-key-deleted: SSH key deleted
|
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), "-"), "-")
|
return strings.Trim(re.ReplaceAllString(strings.ToLower(s), "-"), "-")
|
||||||
},
|
},
|
||||||
"avatarUrl": func(user *db.User, noGravatar bool) string {
|
"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 != "" {
|
if user.AvatarURL != "" {
|
||||||
return user.AvatarURL
|
return user.AvatarURL
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ func (s *Server) registerRoutes() {
|
|||||||
|
|
||||||
r.GET("/healthcheck", health.Healthcheck)
|
r.GET("/healthcheck", health.Healthcheck)
|
||||||
|
|
||||||
|
r.Static("/avatar", settings.AvatarsDir())
|
||||||
|
|
||||||
r.GET("/register", auth.Register)
|
r.GET("/register", auth.Register)
|
||||||
r.POST("/register", auth.ProcessRegister)
|
r.POST("/register", auth.ProcessRegister)
|
||||||
r.GET("/login", auth.Login)
|
r.GET("/login", auth.Login)
|
||||||
@@ -64,6 +66,8 @@ func (s *Server) registerRoutes() {
|
|||||||
sA.GET("/style", settings.UserStyle)
|
sA.GET("/style", settings.UserStyle)
|
||||||
sA.POST("/style", settings.ProcessUserStyle)
|
sA.POST("/style", settings.ProcessUserStyle)
|
||||||
sA.POST("/email", settings.EmailProcess)
|
sA.POST("/email", settings.EmailProcess)
|
||||||
|
sA.POST("/avatar", settings.AvatarProcess)
|
||||||
|
sA.DELETE("/avatar", settings.AvatarDelete)
|
||||||
sA.DELETE("/account", settings.AccountDeleteProcess)
|
sA.DELETE("/account", settings.AccountDeleteProcess)
|
||||||
sA.POST("/ssh-keys", settings.SshKeysProcess)
|
sA.POST("/ssh-keys", settings.SshKeysProcess)
|
||||||
sA.DELETE("/ssh-keys/:id", settings.SshKeysDelete)
|
sA.DELETE("/ssh-keys/:id", settings.SshKeysDelete)
|
||||||
|
|||||||
+38
@@ -1,6 +1,44 @@
|
|||||||
{{ template "header" .}}
|
{{ template "header" .}}
|
||||||
{{ template "settings_header" .}}
|
{{ template "settings_header" .}}
|
||||||
<div class="relative mx-auto max-w-160 space-y-8">
|
<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="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="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">
|
<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