mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-11 11:14:16 +00:00
feat: add ability to set default profile picture (#1061)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
@@ -62,7 +62,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
||||
}
|
||||
|
||||
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
|
||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService)
|
||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService)
|
||||
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
|
||||
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
||||
|
||||
|
||||
@@ -388,3 +388,13 @@ func (e *UserEmailNotSetError) Error() string {
|
||||
func (e *UserEmailNotSetError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
type ImageNotFoundError struct{}
|
||||
|
||||
func (e *ImageNotFoundError) Error() string {
|
||||
return "Image not found"
|
||||
}
|
||||
|
||||
func (e *ImageNotFoundError) HttpStatusCode() int {
|
||||
return http.StatusNotFound
|
||||
}
|
||||
|
||||
@@ -25,10 +25,14 @@ func NewAppImagesController(
|
||||
group.GET("/application-images/logo", controller.getLogoHandler)
|
||||
group.GET("/application-images/background", controller.getBackgroundImageHandler)
|
||||
group.GET("/application-images/favicon", controller.getFaviconHandler)
|
||||
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
|
||||
|
||||
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
|
||||
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
|
||||
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
||||
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
|
||||
|
||||
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
|
||||
}
|
||||
|
||||
type AppImagesController struct {
|
||||
@@ -78,6 +82,18 @@ func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
|
||||
c.getImage(ctx, "favicon")
|
||||
}
|
||||
|
||||
// getDefaultProfilePicture godoc
|
||||
// @Summary Get default profile picture image
|
||||
// @Description Get the default profile picture image for the application
|
||||
// @Tags Application Images
|
||||
// @Produce image/png
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary "Default profile picture image"
|
||||
// @Router /api/application-images/default-profile-picture [get]
|
||||
func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) {
|
||||
c.getImage(ctx, "default-profile-picture")
|
||||
}
|
||||
|
||||
// updateLogoHandler godoc
|
||||
// @Summary Update logo
|
||||
// @Description Update the application logo
|
||||
@@ -171,3 +187,41 @@ func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
|
||||
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
|
||||
ctx.File(imagePath)
|
||||
}
|
||||
|
||||
// updateDefaultProfilePicture godoc
|
||||
// @Summary Update default profile picture image
|
||||
// @Description Update the default profile picture image
|
||||
// @Tags Application Images
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "Profile picture image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/default-profile-picture [put]
|
||||
func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) {
|
||||
file, err := ctx.FormFile("file")
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.appImagesService.UpdateImage(file, "default-profile-picture"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteDefaultProfilePicture godoc
|
||||
// @Summary Delete default profile picture image
|
||||
// @Description Delete the default profile picture image
|
||||
// @Tags Application Images
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/default-profile-picture [delete]
|
||||
func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) {
|
||||
if err := c.appImagesService.DeleteImage("default-profile-picture"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ func (s *AppImagesService) UpdateImage(file *multipart.FileHeader, imageName str
|
||||
|
||||
currentExt, ok := s.extensions[imageName]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown application image '%s'", imageName)
|
||||
s.extensions[imageName] = fileType
|
||||
}
|
||||
|
||||
imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", imageName, fileType))
|
||||
@@ -69,13 +69,39 @@ func (s *AppImagesService) UpdateImage(file *multipart.FileHeader, imageName str
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppImagesService) DeleteImage(imageName string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
ext, ok := s.extensions[imageName]
|
||||
if !ok || ext == "" {
|
||||
return &common.ImageNotFoundError{}
|
||||
}
|
||||
|
||||
imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", imageName+"."+ext)
|
||||
if err := os.Remove(imagePath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(s.extensions, imageName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppImagesService) IsDefaultProfilePictureSet() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
_, ok := s.extensions["default-profile-picture"]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *AppImagesService) getExtension(name string) (string, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
ext, ok := s.extensions[name]
|
||||
if !ok || ext == "" {
|
||||
return "", fmt.Errorf("unknown application image '%s'", name)
|
||||
return "", &common.ImageNotFoundError{}
|
||||
}
|
||||
|
||||
return strings.ToLower(ext), nil
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,9 +34,10 @@ type UserService struct {
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
customClaimService *CustomClaimService
|
||||
appImagesService *AppImagesService
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService) *UserService {
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService) *UserService {
|
||||
return &UserService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
@@ -43,6 +45,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
|
||||
emailService: emailService,
|
||||
appConfigService: appConfigService,
|
||||
customClaimService: customClaimService,
|
||||
appImagesService: appImagesService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,39 +90,42 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
|
||||
return nil, 0, &common.InvalidUUIDError{}
|
||||
}
|
||||
|
||||
// First check for a custom uploaded profile picture (userID.png)
|
||||
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png"
|
||||
file, err := os.Open(profilePicturePath)
|
||||
if err == nil {
|
||||
// Get the file size
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
return file, fileInfo.Size(), nil
|
||||
}
|
||||
|
||||
// If no custom picture exists, get the user's data for creating initials
|
||||
user, err := s.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Check if we have a cached default picture for these initials
|
||||
defaultProfilePicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults/"
|
||||
defaultPicturePath := defaultProfilePicturesDir + user.Initials() + ".png"
|
||||
file, err = os.Open(defaultPicturePath)
|
||||
if err == nil {
|
||||
fileInfo, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
return file, fileInfo.Size(), nil
|
||||
profilePicturePath := filepath.Join(common.EnvConfig.UploadPath, "profile-pictures", userID+".png")
|
||||
|
||||
// Try custom profile picture
|
||||
if file, size, err := utils.OpenFileWithSize(profilePicturePath); err == nil {
|
||||
return file, size, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// If no cached default picture exists, create one and save it for future use
|
||||
// Try default global profile picture
|
||||
if s.appImagesService.IsDefaultProfilePictureSet() {
|
||||
path, _, err := s.appImagesService.GetImage("default-profile-picture")
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if file, size, err := utils.OpenFileWithSize(path); err == nil {
|
||||
return file, size, nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
|
||||
// Try cached default for initials
|
||||
defaultProfilePicturesDir := filepath.Join(common.EnvConfig.UploadPath, "profile-pictures", "defaults")
|
||||
defaultPicturePath := filepath.Join(defaultProfilePicturesDir, user.Initials()+".png")
|
||||
|
||||
if file, size, err := utils.OpenFileWithSize(defaultPicturePath); err == nil {
|
||||
return file, size, nil
|
||||
}
|
||||
|
||||
// Create and return generated default with initials
|
||||
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.Initials())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -128,19 +134,16 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
|
||||
// Save the default picture for future use (in a goroutine to avoid blocking)
|
||||
defaultPictureBytes := defaultPicture.Bytes()
|
||||
go func() {
|
||||
// Ensure the directory exists
|
||||
errInternal := os.MkdirAll(defaultProfilePicturesDir, os.ModePerm)
|
||||
if errInternal != nil {
|
||||
slog.Error("Failed to create directory for default profile picture", slog.Any("error", errInternal))
|
||||
if err := os.MkdirAll(defaultProfilePicturesDir, os.ModePerm); err != nil {
|
||||
slog.Error("Failed to create directory for default profile picture", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
errInternal = utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath)
|
||||
if errInternal != nil {
|
||||
slog.Error("Failed to cache default profile picture for initials", slog.String("initials", user.Initials()), slog.Any("error", errInternal))
|
||||
if err := utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath); err != nil {
|
||||
slog.Error("Failed to cache default profile picture", slog.String("initials", user.Initials()), slog.Any("error", err))
|
||||
}
|
||||
}()
|
||||
|
||||
return io.NopCloser(bytes.NewReader(defaultPictureBytes)), int64(defaultPicture.Len()), nil
|
||||
return io.NopCloser(bytes.NewReader(defaultPictureBytes)), int64(len(defaultPictureBytes)), nil
|
||||
}
|
||||
|
||||
func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model.UserGroup, error) {
|
||||
|
||||
@@ -239,3 +239,17 @@ func IsWritableDir(dir string) (bool, error) {
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// OpenFileWithSize opens a file and returns its size
|
||||
func OpenFileWithSize(path string) (io.ReadCloser, int64, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
return f, info.Size(), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user