diff --git a/backend/internal/bootstrap/application_images_bootstrap.go b/backend/internal/bootstrap/application_images_bootstrap.go index f9ccd23b..cac8c9eb 100644 --- a/backend/internal/bootstrap/application_images_bootstrap.go +++ b/backend/internal/bootstrap/application_images_bootstrap.go @@ -1,9 +1,13 @@ package bootstrap import ( + "bytes" + "encoding/hex" "fmt" + "io/fs" + "log/slog" "os" - "path" + "path/filepath" "strings" "github.com/pocket-id/pocket-id/backend/internal/common" @@ -13,6 +17,15 @@ 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() + + // Previous versions of images + // If these are found, they are deleted + legacyImageHashes := imageHashMap{ + "background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"), + } + dirPath := common.EnvConfig.UploadPath + "/application-images" sourceFiles, err := resources.FS.ReadDir("images") @@ -24,15 +37,48 @@ func initApplicationImages() error { if err != nil && !os.IsNotExist(err) { return fmt.Errorf("failed to read directory: %w", err) } + destinationFilesMap := make(map[string]bool, len(destinationFiles)) + for _, f := range destinationFiles { + name := f.Name() + destFilePath := filepath.Join(dirPath, name) + + h, err := utils.CreateSha256FileHash(destFilePath) + if err != nil { + return fmt.Errorf("failed to get hash for file '%s': %w", name, err) + } + + // Check if the file is a legacy one - if so, delete it + if legacyImageHashes.Contains(h) { + 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) + } + continue + } + + // Check if the file is a built-in one and save it in the map + destinationFilesMap[getImageNameWithoutExtension(name)] = builtInImageHashes.Contains(h) + } // Copy images from the images directory to the application-images directory if they don't already exist for _, sourceFile := range sourceFiles { - if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) { + // Skip if it's a directory + if sourceFile.IsDir() { continue } - srcFilePath := path.Join("images", sourceFile.Name()) - destFilePath := path.Join(dirPath, sourceFile.Name()) + name := sourceFile.Name() + srcFilePath := filepath.Join("images", name) + destFilePath := filepath.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) { + 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) @@ -42,25 +88,49 @@ func initApplicationImages() error { return nil } -func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool { - for _, destinationFile := range destinationFiles { - sourceFileWithoutExtension := getImageNameWithoutExtension(fileName) - destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name()) +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"), + } +} - if sourceFileWithoutExtension == destinationFileWithoutExtension { +type imageHashMap map[string][]byte + +func (m imageHashMap) Contains(target []byte) bool { + if len(target) == 0 { + return false + } + for _, h := range m { + if bytes.Equal(h, target) { return true } } - 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 { + panic(err) + } + return b +} diff --git a/backend/internal/bootstrap/application_images_bootstrap_test.go b/backend/internal/bootstrap/application_images_bootstrap_test.go new file mode 100644 index 00000000..e3f0bab4 --- /dev/null +++ b/backend/internal/bootstrap/application_images_bootstrap_test.go @@ -0,0 +1,61 @@ +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/service/app_config_service.go b/backend/internal/service/app_config_service.go index 9a4fe7ee..996c8b02 100644 --- a/backend/internal/service/app_config_service.go +++ b/backend/internal/service/app_config_service.go @@ -70,7 +70,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig { SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"}, AccentColor: model.AppConfigVariable{Value: "default"}, // Internal - BackgroundImageType: model.AppConfigVariable{Value: "jpg"}, + BackgroundImageType: model.AppConfigVariable{Value: "webp"}, LogoLightImageType: model.AppConfigVariable{Value: "svg"}, LogoDarkImageType: model.AppConfigVariable{Value: "svg"}, InstanceID: model.AppConfigVariable{Value: ""}, diff --git a/backend/internal/utils/email/email_service_templates.go b/backend/internal/utils/email/email_service_templates.go index dbae900f..6b7e8de3 100644 --- a/backend/internal/utils/email/email_service_templates.go +++ b/backend/internal/utils/email/email_service_templates.go @@ -3,7 +3,7 @@ package email import ( "fmt" htemplate "html/template" - "path" + "path/filepath" ttemplate "text/template" "github.com/pocket-id/pocket-id/backend/resources" @@ -30,7 +30,7 @@ func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, e textTemplates := make(map[string]*ttemplate.Template, len(templates)) for _, tmpl := range templates { filename := tmpl + "_text.tmpl" - templatePath := path.Join("email-templates", filename) + templatePath := filepath.Join("email-templates", filename) parsedTemplate, err := ttemplate.ParseFS(resources.FS, templatePath) if err != nil { @@ -47,7 +47,7 @@ func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, e htmlTemplates := make(map[string]*htemplate.Template, len(templates)) for _, tmpl := range templates { filename := tmpl + "_html.tmpl" - templatePath := path.Join("email-templates", filename) + templatePath := filepath.Join("email-templates", filename) parsedTemplate, err := htemplate.ParseFS(resources.FS, templatePath) if err != nil { diff --git a/backend/internal/utils/file_util.go b/backend/internal/utils/file_util.go index 3d2fde71..513e56a6 100644 --- a/backend/internal/utils/file_util.go +++ b/backend/internal/utils/file_util.go @@ -2,6 +2,7 @@ package utils import ( "crypto/rand" + "crypto/sha256" "encoding/hex" "errors" "fmt" @@ -35,6 +36,12 @@ func GetImageMimeType(ext string) string { return "image/x-icon" case "gif": return "image/gif" + case "webp": + return "image/webp" + case "avif": + return "image/avif" + case "heic": + return "image/heic" default: return "" } @@ -43,29 +50,45 @@ func GetImageMimeType(ext string) string { func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error { srcFile, err := resources.FS.Open(srcFilePath) if err != nil { - return err + return fmt.Errorf("failed to open embedded file: %w", err) } defer srcFile.Close() err = os.MkdirAll(filepath.Dir(destFilePath), os.ModePerm) if err != nil { - return err + return fmt.Errorf("failed to create destination directory: %w", err) } destFile, err := os.Create(destFilePath) if err != nil { - return err + return fmt.Errorf("failed to open destination file: %w", err) } defer destFile.Close() _, err = io.Copy(destFile, srcFile) if err != nil { - return err + return fmt.Errorf("failed to write to destination file: %w", err) } return nil } +func EmbeddedFileSha256(filePath string) ([]byte, error) { + f, err := resources.FS.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open embedded file: %w", err) + } + defer f.Close() + + h := sha256.New() + _, err = io.Copy(h, f) + if err != nil { + return nil, fmt.Errorf("failed to read embedded file: %w", err) + } + + return h.Sum(nil), nil +} + func SaveFile(file *multipart.FileHeader, dst string) error { src, err := file.Open() if err != nil { diff --git a/backend/internal/utils/hash_util.go b/backend/internal/utils/hash_util.go index d80c41f1..0fc37d27 100644 --- a/backend/internal/utils/hash_util.go +++ b/backend/internal/utils/hash_util.go @@ -3,9 +3,28 @@ package utils import ( "crypto/sha256" "encoding/hex" + "fmt" + "io" + "os" ) func CreateSha256Hash(input string) string { hash := sha256.Sum256([]byte(input)) return hex.EncodeToString(hash[:]) } + +func CreateSha256FileHash(filePath string) ([]byte, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + defer f.Close() + + h := sha256.New() + _, err = io.Copy(h, f) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + return h.Sum(nil), nil +} diff --git a/backend/resources/images/background.jpg b/backend/resources/images/background.jpg deleted file mode 100644 index 1f4c77e6..00000000 Binary files a/backend/resources/images/background.jpg and /dev/null differ diff --git a/backend/resources/images/background.webp b/backend/resources/images/background.webp new file mode 100644 index 00000000..7950cac6 Binary files /dev/null and b/backend/resources/images/background.webp differ diff --git a/frontend/src/lib/components/form/profile-picture-settings.svelte b/frontend/src/lib/components/form/profile-picture-settings.svelte index a2ab355c..4ac8d44e 100644 --- a/frontend/src/lib/components/form/profile-picture-settings.svelte +++ b/frontend/src/lib/components/form/profile-picture-settings.svelte @@ -81,7 +81,7 @@
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 5a66c83b..26e88790 100644 --- a/frontend/src/routes/settings/admin/application-configuration/application-image.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/application-image.svelte @@ -11,7 +11,7 @@ label, image = $bindable(), imageURL, - accept = 'image/png, image/jpeg, image/svg+xml, image/gif', + accept = 'image/png, image/jpeg, image/svg+xml, image/gif, image/webp, image/avif, image/heic', forceColorScheme, ...restProps }: HTMLAttributes & { diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte index d0f141d3..0ddd82ff 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte @@ -187,7 +187,7 @@