mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-04 08:49:03 +02:00
feat: add animated logo as default logo
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 +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 +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 +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 |
Reference in New Issue
Block a user