diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index c7f085a0..fb31f1e8 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -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) diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index d81240a9..9d35c0de 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -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 +} diff --git a/backend/internal/controller/app_images_controller.go b/backend/internal/controller/app_images_controller.go index f563a18a..908eb55f 100644 --- a/backend/internal/controller/app_images_controller.go +++ b/backend/internal/controller/app_images_controller.go @@ -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) +} diff --git a/backend/internal/service/app_images_service.go b/backend/internal/service/app_images_service.go index e3730f6a..5337f814 100644 --- a/backend/internal/service/app_images_service.go +++ b/backend/internal/service/app_images_service.go @@ -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 diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 18b79804..b9ab4e4e 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -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) { diff --git a/backend/internal/utils/file_util.go b/backend/internal/utils/file_util.go index 89bcc9a0..1bb19dee 100644 --- a/backend/internal/utils/file_util.go +++ b/backend/internal/utils/file_util.go @@ -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 +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a0dcfb86..338c4c0d 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -155,7 +155,7 @@ "are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.", "last_used": "Last Used", "actions": "Actions", - "images_updated_successfully": "Images updated successfully", + "images_updated_successfully": "Images updated successfully. It may take a few minutes to update.", "general": "General", "configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.", "ldap": "LDAP", @@ -459,7 +459,8 @@ "view": "View", "toggle_columns": "Toggle columns", "locale": "Locale", - "ldap_id" : "LDAP ID", + "ldap_id": "LDAP ID", "reauthentication": "Re-authentication", - "clear_filters" : "Clear Filters" + "clear_filters": "Clear Filters", + "default_profile_picture": "Default Profile Picture" } diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte index 221c197f..765b13a9 100644 --- a/frontend/src/lib/components/ui/button/button.svelte +++ b/frontend/src/lib/components/ui/button/button.svelte @@ -55,9 +55,13 @@ disabled, isLoading = false, autofocus = false, + onclick, + usePromiseLoading = false, children, ...restProps - }: ButtonProps = $props(); + }: ButtonProps & { + usePromiseLoading?: boolean; + } = $props(); onMount(async () => { // Using autofocus can be bad for a11y, but in the case of Pocket ID is only used responsibly in places where there are not many choices, and on buttons only where there's descriptive text @@ -66,6 +70,19 @@ setTimeout(() => ref?.focus(), 100); } }); + + async function handleOnClick(event: any) { + if (usePromiseLoading && onclick) { + isLoading = true; + try { + await onclick(event); + } finally { + isLoading = false; + } + } else { + onclick?.(event); + } + } {#if href} @@ -91,6 +108,7 @@ class={cn(buttonVariants({ variant, size }), className)} {type} disabled={disabled || isLoading} + onclick={handleOnClick} {...restProps} > {#if isLoading} diff --git a/frontend/src/lib/services/app-config-service.ts b/frontend/src/lib/services/app-config-service.ts index 01b6b2ec..1e8e7f64 100644 --- a/frontend/src/lib/services/app-config-service.ts +++ b/frontend/src/lib/services/app-config-service.ts @@ -1,5 +1,12 @@ +import userStore from '$lib/stores/user-store'; import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration'; -import { cachedApplicationLogo, cachedBackgroundImage } from '$lib/utils/cached-image-util'; +import { + cachedApplicationLogo, + cachedBackgroundImage, + cachedDefaultProfilePicture, + cachedProfilePicture +} from '$lib/utils/cached-image-util'; +import { get } from 'svelte/store'; import APIService from './api-service'; export default class AppConfigService extends APIService { @@ -24,14 +31,14 @@ export default class AppConfigService extends APIService { updateFavicon = async (favicon: File) => { const formData = new FormData(); - formData.append('file', favicon!); + formData.append('file', favicon); await this.api.put(`/application-images/favicon`, formData); - } + }; updateLogo = async (logo: File, light = true) => { const formData = new FormData(); - formData.append('file', logo!); + formData.append('file', logo); await this.api.put(`/application-images/logo`, formData, { params: { light } @@ -39,6 +46,14 @@ export default class AppConfigService extends APIService { cachedApplicationLogo.bustCache(light); }; + updateDefaultProfilePicture = async (defaultProfilePicture: File) => { + const formData = new FormData(); + formData.append('file', defaultProfilePicture); + + await this.api.put(`/application-images/default-profile-picture`, formData); + cachedDefaultProfilePicture.bustCache(); + }; + updateBackgroundImage = async (backgroundImage: File) => { const formData = new FormData(); formData.append('file', backgroundImage!); @@ -47,6 +62,12 @@ export default class AppConfigService extends APIService { cachedBackgroundImage.bustCache(); }; + deleteDefaultProfilePicture = async () => { + await this.api.delete('/application-images/default-profile-picture'); + cachedDefaultProfilePicture.bustCache(); + cachedProfilePicture.bustCache(get(userStore)!.id); + }; + sendTestEmail = async () => { await this.api.post('/application-configuration/test-email'); }; diff --git a/frontend/src/lib/utils/cached-image-util.ts b/frontend/src/lib/utils/cached-image-util.ts index 05c69a91..fc8659a7 100644 --- a/frontend/src/lib/utils/cached-image-util.ts +++ b/frontend/src/lib/utils/cached-image-util.ts @@ -20,6 +20,13 @@ export const cachedApplicationLogo: CachableImage = { } }; +export const cachedDefaultProfilePicture: CachableImage = { + getUrl: () => + getCachedImageUrl(new URL('/api/application-images/default-profile-picture', window.location.origin)), + bustCache: () => + bustImageCache(new URL('/api/application-images/default-profile-picture', window.location.origin)) +}; + export const cachedBackgroundImage: CachableImage = { getUrl: () => getCachedImageUrl(new URL('/api/application-images/background', window.location.origin)), diff --git a/frontend/src/routes/settings/admin/application-configuration/+page.svelte b/frontend/src/routes/settings/admin/application-configuration/+page.svelte index fea27fe8..05566b01 100644 --- a/frontend/src/routes/settings/admin/application-configuration/+page.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/+page.svelte @@ -40,23 +40,40 @@ } async function updateImages( - logoLight: File | null, - logoDark: File | null, - backgroundImage: File | null, - favicon: File | null + logoLight: File | undefined, + logoDark: File | undefined, + defaultProfilePicture: File | null | undefined, + backgroundImage: File | undefined, + favicon: File | undefined ) { const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve(); + const lightLogoPromise = logoLight ? appConfigService.updateLogo(logoLight, true) : Promise.resolve(); + const darkLogoPromise = logoDark ? appConfigService.updateLogo(logoDark, false) : Promise.resolve(); + + const defaultProfilePicturePromise = + defaultProfilePicture === null + ? appConfigService.deleteDefaultProfilePicture() + : defaultProfilePicture + ? appConfigService.updateDefaultProfilePicture(defaultProfilePicture) + : Promise.resolve(); + const backgroundImagePromise = backgroundImage ? appConfigService.updateBackgroundImage(backgroundImage) : Promise.resolve(); - await Promise.all([lightLogoPromise, darkLogoPromise, backgroundImagePromise, faviconPromise]) + await Promise.all([ + lightLogoPromise, + darkLogoPromise, + defaultProfilePicturePromise, + backgroundImagePromise, + faviconPromise + ]) .then(() => toast.success(m.images_updated_successfully())) .catch(axiosErrorToast); } diff --git a/frontend/src/routes/settings/admin/application-configuration/application-image.svelte b/frontend/src/routes/settings/admin/application-configuration/application-image.svelte index 86c0e1ed..12b53d42 100644 --- a/frontend/src/routes/settings/admin/application-configuration/application-image.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/application-image.svelte @@ -1,8 +1,9 @@