feat: add animated logo as default logo

This commit is contained in:
Elias Schneider
2026-08-08 20:50:37 +02:00
parent 987d1a8b59
commit 0c9a03e519
16 changed files with 367 additions and 87 deletions
@@ -19,16 +19,32 @@ import (
"github.com/pocket-id/pocket-id/backend/resources"
)
// initApplicationImages copies the images from the embedded directory to the storage backend
// and returns a map containing the detected file extensions in the application-images directory.
const (
applicationImagesPath = "application-images"
deletedApplicationImagesPath = applicationImagesPath + "/.deleted"
legacyApplicationImagesInitedPath = applicationImagesPath + "/.inited"
deletableBundledApplicationImage = "background"
)
// initApplicationImages copies embedded images to storage and returns the detected file extensions
//
//nolint:gocognit
func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
// Previous versions of images
// If these are found, they are deleted
legacyImageHashes := imageHashMap{
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
"background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
"logoLight.svg": {
mustDecodeHex("6d42c88cf6668f7e57c4f2a505e71ecc8a1e0a27534632aa6adec87b812d0bb0"),
},
"logoDark.svg": {
mustDecodeHex("0421a8d93714bacf54c78430f1db378fd0d29565f6de59b6a89090d44a82eb16"),
},
"background.jpg": {
mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
},
"background.webp": {
mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
},
}
sourceFiles, err := resources.FS.ReadDir("images")
@@ -36,7 +52,7 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
return nil, fmt.Errorf("failed to read directory: %w", err)
}
destinationFiles, err := fileStorage.List(ctx, "application-images")
destinationFiles, err := fileStorage.List(ctx, applicationImagesPath)
if err != nil {
if storage.IsNotExist(err) {
destinationFiles = []storage.ObjectInfo{}
@@ -46,13 +62,20 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
}
dstNameToExt := make(map[string]string, len(destinationFiles))
listedImageNames := make(map[string]struct{}, len(destinationFiles))
for _, f := range destinationFiles {
// Skip directories
// Skip bootstrap state that recursive storage backends may include in the listing
if f.Path == legacyApplicationImagesInitedPath || strings.HasPrefix(f.Path, deletedApplicationImagesPath+"/") {
continue
}
// Skip directory entries returned by storage backends
_, name := path.Split(f.Path)
if name == "" {
continue
}
nameWithoutExt, ext := utils.SplitFileName(name)
listedImageNames[nameWithoutExt] = struct{}{}
reader, _, err := fileStorage.Open(ctx, f.Path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
@@ -68,8 +91,8 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
continue
}
// Check if the file is a legacy one - if so, delete it
if legacyImageHashes.Contains(hash) {
// Remove bundled legacy images so their current versions can be restored
if legacyImageHashes.Matches(name, hash) {
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
if err := fileStorage.Delete(ctx, f.Path); err != nil {
return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
@@ -79,19 +102,25 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
dstNameToExt[nameWithoutExt] = ext
}
initedPath := path.Join("application-images", ".inited")
if _, _, err := fileStorage.Open(ctx, initedPath); err == nil {
return dstNameToExt, nil
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read .inited: %w", err)
} else {
err := fileStorage.Save(ctx, initedPath, strings.NewReader(""))
if err != nil {
return nil, fmt.Errorf("failed to store .inited: %w", err)
// Preserve an intentionally deleted background when replacing the legacy global initialization marker
legacyInited, err := storageObjectExists(ctx, fileStorage, legacyApplicationImagesInitedPath)
if err != nil {
return nil, fmt.Errorf("failed to read legacy application images marker: %w", err)
}
if legacyInited {
_, backgroundWasPresent := listedImageNames[deletableBundledApplicationImage]
if !backgroundWasPresent {
deletedPath := deletedApplicationImagePath(deletableBundledApplicationImage)
if err := fileStorage.Save(ctx, deletedPath, strings.NewReader("")); err != nil {
return nil, fmt.Errorf("failed to store deleted application image marker '%s': %w", deletableBundledApplicationImage, err)
}
}
if err := fileStorage.Delete(ctx, legacyApplicationImagesInitedPath); err != nil {
return nil, fmt.Errorf("failed to remove legacy application images marker: %w", err)
}
}
// Copy images from the images directory to the application-images directory if they don't already exist
// Copy missing bundled images unless an administrator intentionally deleted them
for _, sourceFile := range sourceFiles {
if sourceFile.IsDir() {
continue
@@ -104,13 +133,20 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
if _, exists := dstNameToExt[nameWithoutExt]; exists {
continue
}
deleted, err := storageObjectExists(ctx, fileStorage, deletedApplicationImagePath(nameWithoutExt))
if err != nil {
return nil, fmt.Errorf("failed to read deleted application image marker '%s': %w", nameWithoutExt, err)
}
if deleted {
continue
}
slog.Info("Writing new application image", slog.String("name", name))
srcFile, err := resources.FS.Open(srcFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open embedded file '%s': %w", name, err)
}
if err := fileStorage.Save(ctx, path.Join("application-images", name), srcFile); err != nil {
if err := fileStorage.Save(ctx, path.Join(applicationImagesPath, name), srcFile); err != nil {
srcFile.Close()
return nil, fmt.Errorf("failed to store application image '%s': %w", name, err)
}
@@ -121,20 +157,36 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
return dstNameToExt, nil
}
type imageHashMap map[string][]byte
type imageHashMap map[string][][]byte
func (m imageHashMap) Contains(target []byte) bool {
func (m imageHashMap) Matches(name string, target []byte) bool {
if len(target) == 0 {
return false
}
for _, h := range m {
if bytes.Equal(h, target) {
for _, hash := range m[name] {
if bytes.Equal(hash, target) {
return true
}
}
return false
}
func deletedApplicationImagePath(name string) string {
return path.Join(deletedApplicationImagesPath, name)
}
func storageObjectExists(ctx context.Context, fileStorage storage.FileStorage, objectPath string) (bool, error) {
reader, _, err := fileStorage.Open(ctx, objectPath)
if err == nil {
reader.Close()
return true, nil
}
if storage.IsNotExist(err) {
return false, nil
}
return false, err
}
func mustDecodeHex(str string) []byte {
b, err := hex.DecodeString(str)
if err != nil {
@@ -37,6 +37,7 @@ func NewAppImagesController(
group.PUT("/application-images/favicon", authMiddleware.Add(), httpserver.Handle(controller.updateFaviconHandler))
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.updateDefaultProfilePicture))
group.DELETE("/application-images/logo", authMiddleware.Add(), httpserver.Handle(controller.deleteLogoHandler))
group.DELETE("/application-images/background", authMiddleware.Add(), httpserver.Handle(controller.deleteBackgroundImageHandler))
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), httpserver.Handle(controller.deleteDefaultProfilePicture))
}
@@ -56,13 +57,7 @@ type AppImagesController struct {
// @Success 200 {file} binary "Logo image"
// @Router /api/application-images/logo [get]
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) error {
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
return c.getImage(ctx, imageName)
return c.getImage(ctx, logoImageName(ctx))
}
// getEmailLogoHandler godoc
@@ -127,13 +122,7 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) error {
return err
}
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, imageName); err != nil {
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, logoImageName(ctx)); err != nil {
return err
}
@@ -141,6 +130,30 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) error {
return nil
}
// deleteLogoHandler godoc
// @Summary Delete logo image
// @Description Delete the custom application logo and restore the default logo
// @Tags Application Images
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 204 "No Content"
// @Router /api/application-images/logo [delete]
func (c *AppImagesController) deleteLogoHandler(ctx *gin.Context) error {
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), logoImageName(ctx)); err != nil {
return err
}
ctx.Status(http.StatusNoContent)
return nil
}
func logoImageName(ctx *gin.Context) string {
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
if lightLogo {
return "logoLight"
}
return "logoDark"
}
// updateEmailLogoHandler godoc
// @Summary Update email logo
// @Description Update the email logo for use in emails
+13 -5
View File
@@ -57,10 +57,7 @@ func (s *AppImagesService) UpdateImage(ctx context.Context, file *multipart.File
s.mu.Lock()
defer s.mu.Unlock()
currentExt, ok := s.extensions[imageName]
if !ok {
s.extensions[imageName] = fileType
}
currentExt := s.extensions[imageName]
imagePath := path.Join("application-images", imageName+"."+fileType)
fileReader, err := file.Open()
@@ -84,9 +81,12 @@ func (s *AppImagesService) UpdateImage(ctx context.Context, file *multipart.File
return err
}
}
s.extensions[imageName] = fileType
if err := s.storage.Delete(ctx, deletedApplicationImagePath(imageName)); err != nil {
return err
}
return nil
}
@@ -99,6 +99,10 @@ func (s *AppImagesService) DeleteImage(ctx context.Context, imageName string) er
return apperror.ImageNotFound()
}
if err := s.storage.Save(ctx, deletedApplicationImagePath(imageName), strings.NewReader("")); err != nil {
return err
}
imagePath := path.Join("application-images", imageName+"."+ext)
if err := s.storage.Delete(ctx, imagePath); err != nil {
return err
@@ -108,6 +112,10 @@ func (s *AppImagesService) DeleteImage(ctx context.Context, imageName string) er
return nil
}
func deletedApplicationImagePath(imageName string) string {
return path.Join("application-images", ".deleted", imageName)
}
func (s *AppImagesService) IsDefaultProfilePictureSet() bool {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -100,10 +100,18 @@ func TestAppImagesService_ErrorsAndFlags(t *testing.T) {
require.NoError(t, service.DeleteImage(context.Background(), "default-profile-picture"))
assert.False(t, service.IsDefaultProfilePictureSet())
reader, size, err := store.Open(context.Background(), deletedApplicationImagePath("default-profile-picture"))
require.NoError(t, err)
assert.Zero(t, size)
require.NoError(t, reader.Close())
err := service.DeleteImage(context.Background(), "default-profile-picture")
err = service.DeleteImage(context.Background(), "default-profile-picture")
require.Error(t, err)
assert.True(t, apperror.IsCode(err, apperror.CodeImageNotFound))
require.NoError(t, service.UpdateImage(context.Background(), newFileHeader(t, "default-profile-picture.png", []byte("new")), "default-profile-picture"))
_, _, err = store.Open(context.Background(), deletedApplicationImagePath("default-profile-picture"))
require.ErrorIs(t, err, fs.ErrNotExist)
})
}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015"><path d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/><style>@media (prefers-color-scheme:dark){#a path{fill:#fff}}@media (prefers-color-scheme:light){#a path{fill:#000}}</style></svg>

Before

Width:  |  Height:  |  Size: 539 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015"><path fill="#fff" d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/></svg>

Before

Width:  |  Height:  |  Size: 427 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015"><path fill="#000" d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/></svg>

Before

Width:  |  Height:  |  Size: 427 B