diff --git a/backend/internal/bootstrap/application_images_bootstrap.go b/backend/internal/bootstrap/app_images_bootstrap.go similarity index 57% rename from backend/internal/bootstrap/application_images_bootstrap.go rename to backend/internal/bootstrap/app_images_bootstrap.go index b0c0c0af..50b4803f 100644 --- a/backend/internal/bootstrap/application_images_bootstrap.go +++ b/backend/internal/bootstrap/app_images_bootstrap.go @@ -4,11 +4,9 @@ import ( "bytes" "encoding/hex" "fmt" - "io/fs" "log/slog" "os" "path" - "strings" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/utils" @@ -16,10 +14,8 @@ import ( ) // initApplicationImages copies the images from the images directory to the application-images directory -func initApplicationImages() error { - // Images that are built into the Pocket ID binary - builtInImageHashes := getBuiltInImageHashes() - +// and returns a map containing the detected file extensions in the application-images directory. +func initApplicationImages() (map[string]string, error) { // Previous versions of images // If these are found, they are deleted legacyImageHashes := imageHashMap{ @@ -30,16 +26,20 @@ func initApplicationImages() error { sourceFiles, err := resources.FS.ReadDir("images") if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to read directory: %w", err) + return nil, fmt.Errorf("failed to read directory: %w", err) } destinationFiles, err := os.ReadDir(dirPath) if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to read directory: %w", err) + return nil, fmt.Errorf("failed to read directory: %w", err) } - destinationFilesMap := make(map[string]bool, len(destinationFiles)) + dstNameToExt := make(map[string]string, len(destinationFiles)) for _, f := range destinationFiles { + if f.IsDir() { + continue + } name := f.Name() + nameWithoutExt, ext := utils.SplitFileName(name) destFilePath := path.Join(dirPath, name) // Skip directories @@ -58,50 +58,43 @@ func initApplicationImages() error { slog.Info("Found legacy application image that will be removed", slog.String("name", name)) err = os.Remove(destFilePath) if err != nil { - return fmt.Errorf("failed to remove legacy file '%s': %w", name, err) + return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err) } continue } - // Check if the file is a built-in one and save it in the map - destinationFilesMap[getImageNameWithoutExtension(name)] = builtInImageHashes.Contains(h) + // Track existing files + dstNameToExt[nameWithoutExt] = ext } // Copy images from the images directory to the application-images directory if they don't already exist for _, sourceFile := range sourceFiles { - // Skip if it's a directory if sourceFile.IsDir() { continue } name := sourceFile.Name() + nameWithoutExt, ext := utils.SplitFileName(name) srcFilePath := path.Join("images", name) destFilePath := path.Join(dirPath, name) // Skip if there's already an image at the path // We do not check the extension because users could have uploaded a different one - if imageAlreadyExists(sourceFile, destinationFilesMap) { + if _, exists := dstNameToExt[nameWithoutExt]; exists { continue } slog.Info("Writing new application image", slog.String("name", name)) err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath) if err != nil { - return fmt.Errorf("failed to copy file: %w", err) + return nil, fmt.Errorf("failed to copy file: %w", err) } + + // Track the newly copied file so it can be included in the extensions map later + dstNameToExt[nameWithoutExt] = ext } - return nil -} - -func getBuiltInImageHashes() imageHashMap { - return imageHashMap{ - "background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"), - "favicon.ico": mustDecodeHex("70f9c4b6bd4781ade5fc96958b1267511751e91957f83c2354fb880b35ec890a"), - "logo.svg": mustDecodeHex("f1e60707df9784152ce0847e3eb59cb68b9015f918ff160376c27ebff1eda796"), - "logoDark.svg": mustDecodeHex("0421a8d93714bacf54c78430f1db378fd0d29565f6de59b6a89090d44a82eb16"), - "logoLight.svg": mustDecodeHex("6d42c88cf6668f7e57c4f2a505e71ecc8a1e0a27534632aa6adec87b812d0bb0"), - } + return dstNameToExt, nil } type imageHashMap map[string][]byte @@ -118,21 +111,6 @@ func (m imageHashMap) Contains(target []byte) bool { return false } -func imageAlreadyExists(sourceFile fs.DirEntry, destinationFiles map[string]bool) bool { - sourceFileWithoutExtension := getImageNameWithoutExtension(sourceFile.Name()) - _, ok := destinationFiles[sourceFileWithoutExtension] - return ok -} - -func getImageNameWithoutExtension(fileName string) string { - idx := strings.LastIndexByte(fileName, '.') - if idx < 1 { - // No dot found, or fileName starts with a dot - return fileName - } - return fileName[:idx] -} - func mustDecodeHex(str string) []byte { b, err := hex.DecodeString(str) if err != nil { diff --git a/backend/internal/bootstrap/application_images_bootstrap_test.go b/backend/internal/bootstrap/application_images_bootstrap_test.go deleted file mode 100644 index e3f0bab4..00000000 --- a/backend/internal/bootstrap/application_images_bootstrap_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package bootstrap - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/pocket-id/pocket-id/backend/internal/utils" -) - -func TestGetBuiltInImageData(t *testing.T) { - // Get the built-in image data map - builtInImages := getBuiltInImageHashes() - - // Read the actual images directory from disk - imagesDir := filepath.Join("..", "..", "resources", "images") - actualFiles, err := os.ReadDir(imagesDir) - require.NoError(t, err, "Failed to read images directory") - - // Create a map of actual files for comparison - actualFilesMap := make(map[string]struct{}) - - // Validate each actual file exists in the built-in data with correct hash - for _, file := range actualFiles { - fileName := file.Name() - if file.IsDir() || strings.HasPrefix(fileName, ".") { - continue - } - - actualFilesMap[fileName] = struct{}{} - - // Check if the file exists in the built-in data - builtInHash, exists := builtInImages[fileName] - assert.True(t, exists, "File %s exists in images directory but not in getBuiltInImageData map", fileName) - - if !exists { - continue - } - - filePath := filepath.Join(imagesDir, fileName) - - // Validate SHA256 hash - actualHash, err := utils.CreateSha256FileHash(filePath) - require.NoError(t, err, "Failed to compute hash for %s", fileName) - assert.Equal(t, actualHash, builtInHash, "SHA256 hash mismatch for file %s", fileName) - } - - // Ensure the built-in data doesn't have extra files that don't exist in the directory - for fileName := range builtInImages { - _, exists := actualFilesMap[fileName] - assert.True(t, exists, "File %s exists in getBuiltInImageData map but not in images directory", fileName) - } - - // Ensure we have at least some files (sanity check) - assert.NotEmpty(t, actualFilesMap, "Images directory should contain at least one file") - assert.Len(t, actualFilesMap, len(builtInImages), "Number of files in directory should match number in built-in data map") -} diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index cba2250b..856c3805 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -21,7 +21,7 @@ func Bootstrap(ctx context.Context) error { } slog.InfoContext(ctx, "Pocket ID is starting") - err = initApplicationImages() + imageExtensions, err := initApplicationImages() if err != nil { return fmt.Errorf("failed to initialize application images: %w", err) } @@ -33,7 +33,7 @@ func Bootstrap(ctx context.Context) error { } // Create all services - svc, err := initServices(ctx, db, httpClient) + svc, err := initServices(ctx, db, httpClient, imageExtensions) if err != nil { return fmt.Errorf("failed to initialize services: %w", err) } diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 78ed56a9..76abff3c 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -85,6 +85,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) { controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService) controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.appConfigService) controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService) + controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService) controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware) controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService) controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService) @@ -181,9 +182,9 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) { func initLogger(r *gin.Engine) { loggerSkipPathsPrefix := []string{ - "GET /api/application-configuration/logo", - "GET /api/application-configuration/background-image", - "GET /api/application-configuration/favicon", + "GET /api/application-images/logo", + "GET /api/application-images/background", + "GET /api/application-images/favicon", "GET /_app", "GET /fonts", "GET /healthz", diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 81eaedeb..fe2c5f61 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -12,6 +12,7 @@ import ( type services struct { appConfigService *service.AppConfigService + appImagesService *service.AppImagesService emailService *service.EmailService geoLiteService *service.GeoLiteService auditLogService *service.AuditLogService @@ -27,7 +28,7 @@ type services struct { } // Initializes all services -func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) { +func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string) (svc *services, err error) { svc = &services{} svc.appConfigService, err = service.NewAppConfigService(ctx, db) @@ -35,6 +36,8 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv return nil, fmt.Errorf("failed to create app config service: %w", err) } + svc.appImagesService = service.NewAppImagesService(imageExtensions) + svc.emailService, err = service.NewEmailService(db, svc.appConfigService) if err != nil { return nil, fmt.Errorf("failed to create email service: %w", err) diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index 0a7a97e6..ab240112 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -3,14 +3,12 @@ package controller import ( "net/http" "strconv" - "time" "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" - "github.com/pocket-id/pocket-id/backend/internal/utils" ) // NewAppConfigController creates a new controller for application configuration endpoints @@ -34,13 +32,6 @@ func NewAppConfigController( group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler) group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler) - group.GET("/application-configuration/logo", acc.getLogoHandler) - group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler) - group.GET("/application-configuration/favicon", acc.getFaviconHandler) - group.PUT("/application-configuration/logo", authMiddleware.Add(), acc.updateLogoHandler) - group.PUT("/application-configuration/favicon", authMiddleware.Add(), acc.updateFaviconHandler) - group.PUT("/application-configuration/background-image", authMiddleware.Add(), acc.updateBackgroundImageHandler) - group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler) group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler) } @@ -129,147 +120,6 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) { c.JSON(http.StatusOK, configVariablesDto) } -// getLogoHandler godoc -// @Summary Get logo image -// @Description Get the logo image for the application -// @Tags Application Configuration -// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" -// @Produce image/png -// @Produce image/jpeg -// @Produce image/svg+xml -// @Success 200 {file} binary "Logo image" -// @Router /api/application-configuration/logo [get] -func (acc *AppConfigController) getLogoHandler(c *gin.Context) { - dbConfig := acc.appConfigService.GetDbConfig() - - lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true")) - - var imageName, imageType string - if lightLogo { - imageName = "logoLight" - imageType = dbConfig.LogoLightImageType.Value - } else { - imageName = "logoDark" - imageType = dbConfig.LogoDarkImageType.Value - } - - acc.getImage(c, imageName, imageType) -} - -// getFaviconHandler godoc -// @Summary Get favicon -// @Description Get the favicon for the application -// @Tags Application Configuration -// @Produce image/x-icon -// @Success 200 {file} binary "Favicon image" -// @Router /api/application-configuration/favicon [get] -func (acc *AppConfigController) getFaviconHandler(c *gin.Context) { - acc.getImage(c, "favicon", "ico") -} - -// getBackgroundImageHandler godoc -// @Summary Get background image -// @Description Get the background image for the application -// @Tags Application Configuration -// @Produce image/png -// @Produce image/jpeg -// @Success 200 {file} binary "Background image" -// @Router /api/application-configuration/background-image [get] -func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) { - imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value - acc.getImage(c, "background", imageType) -} - -// updateLogoHandler godoc -// @Summary Update logo -// @Description Update the application logo -// @Tags Application Configuration -// @Accept multipart/form-data -// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" -// @Param file formData file true "Logo image file" -// @Success 204 "No Content" -// @Router /api/application-configuration/logo [put] -func (acc *AppConfigController) updateLogoHandler(c *gin.Context) { - dbConfig := acc.appConfigService.GetDbConfig() - - lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true")) - - var imageName, imageType string - if lightLogo { - imageName = "logoLight" - imageType = dbConfig.LogoLightImageType.Value - } else { - imageName = "logoDark" - imageType = dbConfig.LogoDarkImageType.Value - } - - acc.updateImage(c, imageName, imageType) -} - -// updateFaviconHandler godoc -// @Summary Update favicon -// @Description Update the application favicon -// @Tags Application Configuration -// @Accept multipart/form-data -// @Param file formData file true "Favicon file (.ico)" -// @Success 204 "No Content" -// @Router /api/application-configuration/favicon [put] -func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) { - file, err := c.FormFile("file") - if err != nil { - _ = c.Error(err) - return - } - - fileType := utils.GetFileExtension(file.Filename) - if fileType != "ico" { - _ = c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"}) - return - } - acc.updateImage(c, "favicon", "ico") -} - -// updateBackgroundImageHandler godoc -// @Summary Update background image -// @Description Update the application background image -// @Tags Application Configuration -// @Accept multipart/form-data -// @Param file formData file true "Background image file" -// @Success 204 "No Content" -// @Router /api/application-configuration/background-image [put] -func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) { - imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value - acc.updateImage(c, "background", imageType) -} - -// getImage is a helper function to serve image files -func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) { - imagePath := common.EnvConfig.UploadPath + "/application-images/" + name + "." + imageType - mimeType := utils.GetImageMimeType(imageType) - - c.Header("Content-Type", mimeType) - - utils.SetCacheControlHeader(c, 15*time.Minute, 24*time.Hour) - c.File(imagePath) -} - -// updateImage is a helper function to update image files -func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) { - file, err := c.FormFile("file") - if err != nil { - _ = c.Error(err) - return - } - - err = acc.appConfigService.UpdateImage(c.Request.Context(), file, imageName, oldImageType) - if err != nil { - _ = c.Error(err) - return - } - - c.Status(http.StatusNoContent) -} - // syncLdapHandler godoc // @Summary Synchronize LDAP // @Description Manually trigger LDAP synchronization diff --git a/backend/internal/controller/app_images_controller.go b/backend/internal/controller/app_images_controller.go new file mode 100644 index 00000000..f563a18a --- /dev/null +++ b/backend/internal/controller/app_images_controller.go @@ -0,0 +1,173 @@ +package controller + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/middleware" + "github.com/pocket-id/pocket-id/backend/internal/service" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +func NewAppImagesController( + group *gin.RouterGroup, + authMiddleware *middleware.AuthMiddleware, + appImagesService *service.AppImagesService, +) { + controller := &AppImagesController{ + appImagesService: appImagesService, + } + + group.GET("/application-images/logo", controller.getLogoHandler) + group.GET("/application-images/background", controller.getBackgroundImageHandler) + group.GET("/application-images/favicon", controller.getFaviconHandler) + + 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) +} + +type AppImagesController struct { + appImagesService *service.AppImagesService +} + +// getLogoHandler godoc +// @Summary Get logo image +// @Description Get the logo image for the application +// @Tags Application Images +// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" +// @Produce image/png +// @Produce image/jpeg +// @Produce image/svg+xml +// @Success 200 {file} binary "Logo image" +// @Router /api/application-images/logo [get] +func (c *AppImagesController) getLogoHandler(ctx *gin.Context) { + lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true")) + imageName := "logoLight" + if !lightLogo { + imageName = "logoDark" + } + + c.getImage(ctx, imageName) +} + +// getBackgroundImageHandler godoc +// @Summary Get background image +// @Description Get the background image for the application +// @Tags Application Images +// @Produce image/png +// @Produce image/jpeg +// @Success 200 {file} binary "Background image" +// @Router /api/application-images/background [get] +func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) { + c.getImage(ctx, "background") +} + +// getFaviconHandler godoc +// @Summary Get favicon +// @Description Get the favicon for the application +// @Tags Application Images +// @Produce image/x-icon +// @Success 200 {file} binary "Favicon image" +// @Router /api/application-images/favicon [get] +func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) { + c.getImage(ctx, "favicon") +} + +// updateLogoHandler godoc +// @Summary Update logo +// @Description Update the application logo +// @Tags Application Images +// @Accept multipart/form-data +// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)" +// @Param file formData file true "Logo image file" +// @Success 204 "No Content" +// @Router /api/application-images/logo [put] +func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) { + file, err := ctx.FormFile("file") + if err != nil { + _ = ctx.Error(err) + return + } + + lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true")) + imageName := "logoLight" + if !lightLogo { + imageName = "logoDark" + } + + if err := c.appImagesService.UpdateImage(file, imageName); err != nil { + _ = ctx.Error(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// updateBackgroundImageHandler godoc +// @Summary Update background image +// @Description Update the application background image +// @Tags Application Images +// @Accept multipart/form-data +// @Param file formData file true "Background image file" +// @Success 204 "No Content" +// @Router /api/application-images/background [put] +func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) { + file, err := ctx.FormFile("file") + if err != nil { + _ = ctx.Error(err) + return + } + + if err := c.appImagesService.UpdateImage(file, "background"); err != nil { + _ = ctx.Error(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +// updateFaviconHandler godoc +// @Summary Update favicon +// @Description Update the application favicon +// @Tags Application Images +// @Accept multipart/form-data +// @Param file formData file true "Favicon file (.ico)" +// @Success 204 "No Content" +// @Router /api/application-images/favicon [put] +func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) { + file, err := ctx.FormFile("file") + if err != nil { + _ = ctx.Error(err) + return + } + + fileType := utils.GetFileExtension(file.Filename) + if fileType != "ico" { + _ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"}) + return + } + + if err := c.appImagesService.UpdateImage(file, "favicon"); err != nil { + _ = ctx.Error(err) + return + } + + ctx.Status(http.StatusNoContent) +} + +func (c *AppImagesController) getImage(ctx *gin.Context, name string) { + imagePath, mimeType, err := c.appImagesService.GetImage(name) + if err != nil { + _ = ctx.Error(err) + return + } + + ctx.Header("Content-Type", mimeType) + utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour) + ctx.File(imagePath) +} diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index bfe1e7d7..09e543a7 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -44,10 +44,7 @@ type AppConfig struct { SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"` SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"` // Internal - BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal - LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal - LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal - InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal + InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal // Email SmtpHost AppConfigVariable `key:"smtpHost"` SmtpPort AppConfigVariable `key:"smtpPort"` diff --git a/backend/internal/service/app_config_service.go b/backend/internal/service/app_config_service.go index 65e845a3..96f3dcf8 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "mime/multipart" "os" "reflect" "strings" @@ -70,10 +69,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig { SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"}, AccentColor: model.AppConfigVariable{Value: "default"}, // Internal - BackgroundImageType: model.AppConfigVariable{Value: "webp"}, - LogoLightImageType: model.AppConfigVariable{Value: "svg"}, - LogoDarkImageType: model.AppConfigVariable{Value: "svg"}, - InstanceID: model.AppConfigVariable{Value: ""}, + InstanceID: model.AppConfigVariable{Value: ""}, // Email SmtpHost: model.AppConfigVariable{}, SmtpPort: model.AppConfigVariable{}, @@ -322,39 +318,6 @@ func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true) } -func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multipart.FileHeader, imageName string, oldImageType string) (err error) { - fileType := strings.ToLower(utils.GetFileExtension(uploadedFile.Filename)) - mimeType := utils.GetImageMimeType(fileType) - if mimeType == "" { - return &common.FileTypeNotSupportedError{} - } - - // Save the updated image - imagePath := common.EnvConfig.UploadPath + "/application-images/" + imageName + "." + fileType - err = utils.SaveFile(uploadedFile, imagePath) - if err != nil { - return err - } - - // Delete the old image if it has a different file type, then update the type in the database - if fileType != oldImageType { - oldImagePath := common.EnvConfig.UploadPath + "/application-images/" + imageName + "." + oldImageType - err = os.Remove(oldImagePath) - if err != nil { - return err - } - - // Update the file type in the database - err = s.UpdateAppConfigValues(ctx, imageName+"ImageType", fileType) - if err != nil { - return err - } - - } - - return nil -} - // LoadDbConfig loads the configuration values from the database into the DbConfig struct. func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) { dest, err := s.loadDbConfigInternal(ctx, s.db) diff --git a/backend/internal/service/app_images_service.go b/backend/internal/service/app_images_service.go new file mode 100644 index 00000000..e3730f6a --- /dev/null +++ b/backend/internal/service/app_images_service.go @@ -0,0 +1,82 @@ +package service + +import ( + "fmt" + "mime/multipart" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +type AppImagesService struct { + mu sync.RWMutex + extensions map[string]string +} + +func NewAppImagesService(extensions map[string]string) *AppImagesService { + return &AppImagesService{extensions: extensions} +} + +func (s *AppImagesService) GetImage(name string) (string, string, error) { + ext, err := s.getExtension(name) + if err != nil { + return "", "", err + } + + mimeType := utils.GetImageMimeType(ext) + if mimeType == "" { + return "", "", fmt.Errorf("unsupported image type '%s'", ext) + } + + imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", name, ext)) + return imagePath, mimeType, nil +} + +func (s *AppImagesService) UpdateImage(file *multipart.FileHeader, imageName string) error { + fileType := strings.ToLower(utils.GetFileExtension(file.Filename)) + mimeType := utils.GetImageMimeType(fileType) + if mimeType == "" { + return &common.FileTypeNotSupportedError{} + } + + s.mu.Lock() + defer s.mu.Unlock() + + currentExt, ok := s.extensions[imageName] + if !ok { + return fmt.Errorf("unknown application image '%s'", imageName) + } + + imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", imageName, fileType)) + + if err := utils.SaveFile(file, imagePath); err != nil { + return err + } + + if currentExt != "" && currentExt != fileType { + oldImagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", imageName, currentExt)) + if err := os.Remove(oldImagePath); err != nil && !os.IsNotExist(err) { + return err + } + } + + s.extensions[imageName] = fileType + + return nil +} + +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 strings.ToLower(ext), nil +} diff --git a/backend/internal/service/app_images_service_test.go b/backend/internal/service/app_images_service_test.go new file mode 100644 index 00000000..0430e895 --- /dev/null +++ b/backend/internal/service/app_images_service_test.go @@ -0,0 +1,88 @@ +package service + +import ( + "bytes" + "io/fs" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/common" +) + +func TestAppImagesService_GetImage(t *testing.T) { + tempDir := t.TempDir() + originalUploadPath := common.EnvConfig.UploadPath + common.EnvConfig.UploadPath = tempDir + t.Cleanup(func() { + common.EnvConfig.UploadPath = originalUploadPath + }) + + imagesDir := filepath.Join(tempDir, "application-images") + require.NoError(t, os.MkdirAll(imagesDir, 0o755)) + + filePath := filepath.Join(imagesDir, "background.webp") + require.NoError(t, os.WriteFile(filePath, []byte("data"), fs.FileMode(0o644))) + + service := NewAppImagesService(map[string]string{"background": "webp"}) + + path, mimeType, err := service.GetImage("background") + require.NoError(t, err) + require.Equal(t, filePath, path) + require.Equal(t, "image/webp", mimeType) +} + +func TestAppImagesService_UpdateImage(t *testing.T) { + tempDir := t.TempDir() + originalUploadPath := common.EnvConfig.UploadPath + common.EnvConfig.UploadPath = tempDir + t.Cleanup(func() { + common.EnvConfig.UploadPath = originalUploadPath + }) + + imagesDir := filepath.Join(tempDir, "application-images") + require.NoError(t, os.MkdirAll(imagesDir, 0o755)) + + oldPath := filepath.Join(imagesDir, "logoLight.svg") + require.NoError(t, os.WriteFile(oldPath, []byte("old"), fs.FileMode(0o644))) + + service := NewAppImagesService(map[string]string{"logoLight": "svg"}) + + fileHeader := newFileHeader(t, "logoLight.png", []byte("new")) + + require.NoError(t, service.UpdateImage(fileHeader, "logoLight")) + + _, err := os.Stat(filepath.Join(imagesDir, "logoLight.png")) + require.NoError(t, err) + + _, err = os.Stat(oldPath) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func newFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader { + t.Helper() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile("file", filename) + require.NoError(t, err) + + _, err = part.Write(content) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + req := httptest.NewRequest(http.MethodPost, "/", body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + _, fileHeader, err := req.FormFile("file") + require.NoError(t, err) + + return fileHeader +} diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 17276a7b..62df2675 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -74,7 +74,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr data := &email.TemplateData[V]{ AppName: dbConfig.AppName.Value, - LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo", + LogoURL: common.EnvConfig.AppURL + "/api/application-images/logo", Data: tData, } diff --git a/backend/internal/utils/file_util.go b/backend/internal/utils/file_util.go index 513e56a6..8216e012 100644 --- a/backend/internal/utils/file_util.go +++ b/backend/internal/utils/file_util.go @@ -10,6 +10,7 @@ import ( "mime/multipart" "os" "path/filepath" + "strings" "syscall" "github.com/google/uuid" @@ -24,6 +25,15 @@ func GetFileExtension(filename string) string { return filename } +// SplitFileName splits a full file name into name and extension. +func SplitFileName(fullName string) (name, ext string) { + dot := strings.LastIndex(fullName, ".") + if dot == -1 || dot == 0 { + return fullName, "" // no extension or hidden file like .gitignore + } + return fullName[:dot], fullName[dot+1:] +} + func GetImageMimeType(ext string) string { switch ext { case "jpg", "jpeg": diff --git a/backend/internal/utils/file_util_test.go b/backend/internal/utils/file_util_test.go index 6c60682a..b7763157 100644 --- a/backend/internal/utils/file_util_test.go +++ b/backend/internal/utils/file_util_test.go @@ -2,8 +2,36 @@ package utils import ( "testing" + + "github.com/stretchr/testify/assert" ) +func TestSplitFileName(t *testing.T) { + t.Parallel() + + tests := []struct { + fullName string + wantName string + wantExt string + }{ + {"background.jpg", "background", "jpg"}, + {"archive.tar.gz", "archive.tar", "gz"}, + {".gitignore", ".gitignore", ""}, + {"noext", "noext", ""}, + {"a.b.c", "a.b", "c"}, + {".hidden.ext", ".hidden", "ext"}, + } + + for _, tc := range tests { + t.Run(tc.fullName, func(t *testing.T) { + t.Parallel() + name, ext := SplitFileName(tc.fullName) + assert.Equal(t, tc.wantName, name) + assert.Equal(t, tc.wantExt, ext) + }) + } +} + func TestGetFileExtension(t *testing.T) { tests := []struct { name string diff --git a/frontend/src/app.html b/frontend/src/app.html index 54fc6a52..98d3fc9b 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -2,7 +2,7 @@ - + diff --git a/frontend/src/lib/services/app-config-service.ts b/frontend/src/lib/services/app-config-service.ts index cf178fdc..73cff7e8 100644 --- a/frontend/src/lib/services/app-config-service.ts +++ b/frontend/src/lib/services/app-config-service.ts @@ -32,14 +32,14 @@ export default class AppConfigService extends APIService { const formData = new FormData(); formData.append('file', favicon!); - await this.api.put(`/application-configuration/favicon`, formData); + await this.api.put(`/application-images/favicon`, formData); } async updateLogo(logo: File, light = true) { const formData = new FormData(); formData.append('file', logo!); - await this.api.put(`/application-configuration/logo`, formData, { + await this.api.put(`/application-images/logo`, formData, { params: { light } }); cachedApplicationLogo.bustCache(light); @@ -49,7 +49,7 @@ export default class AppConfigService extends APIService { const formData = new FormData(); formData.append('file', backgroundImage!); - await this.api.put(`/application-configuration/background-image`, formData); + await this.api.put(`/application-images/background`, formData); cachedBackgroundImage.bustCache(); } diff --git a/frontend/src/lib/utils/cached-image-util.ts b/frontend/src/lib/utils/cached-image-util.ts index 43229d5e..b151f974 100644 --- a/frontend/src/lib/utils/cached-image-util.ts +++ b/frontend/src/lib/utils/cached-image-util.ts @@ -9,14 +9,14 @@ type CachableImage = { export const cachedApplicationLogo: CachableImage = { getUrl: (light = true) => { - let url = '/api/application-configuration/logo'; + let url = '/api/application-images/logo'; if (!light) { url += '?light=false'; } return getCachedImageUrl(url); }, bustCache: (light = true) => { - let url = '/api/application-configuration/logo'; + let url = '/api/application-images/logo'; if (!light) { url += '?light=false'; } @@ -25,8 +25,8 @@ export const cachedApplicationLogo: CachableImage = { }; export const cachedBackgroundImage: CachableImage = { - getUrl: () => getCachedImageUrl('/api/application-configuration/background-image'), - bustCache: () => bustImageCache('/api/application-configuration/background-image') + getUrl: () => getCachedImageUrl('/api/application-images/background'), + bustCache: () => bustImageCache('/api/application-images/background') }; export const cachedProfilePicture: CachableImage = { diff --git a/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte b/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte index 4127d010..b3bb26b7 100644 --- a/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/update-application-images.svelte @@ -27,7 +27,7 @@ imageClass="size-14 p-2" label={m.favicon()} bind:image={favicon} - imageURL="/api/application-configuration/favicon" + imageURL="/api/application-images/favicon" accept="image/x-icon" /> { await expect(page.locator('[data-type="success"]')).toHaveText('Images updated successfully'); await page.request - .get('/api/application-configuration/favicon') + .get('/api/application-images/favicon') .then((res) => expect.soft(res.status()).toBe(200)); await page.request - .get('/api/application-configuration/logo?light=true') + .get('/api/application-images/logo?light=true') .then((res) => expect.soft(res.status()).toBe(200)); await page.request - .get('/api/application-configuration/logo?light=false') + .get('/api/application-images/logo?light=false') .then((res) => expect.soft(res.status()).toBe(200)); await page.request - .get('/api/application-configuration/background-image') + .get('/api/application-images/background') .then((res) => expect.soft(res.status()).toBe(200)); });