From 0c9a03e5193b52ff752134f7df39a24fae19dae2 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Sat, 8 Aug 2026 20:44:18 +0200 Subject: [PATCH] feat: add animated logo as default logo --- README.md | 2 +- .../bootstrap/app_images_bootstrap.go | 98 +++++++++--- .../controller/app_images_controller.go | 41 +++-- .../internal/service/app_images_service.go | 18 ++- .../service/app_images_service_test.go | 10 +- backend/resources/images/logo.svg | 1 - backend/resources/images/logoDark.svg | 1 - backend/resources/images/logoLight.svg | 1 - frontend/src/lib/components/logo.svelte | 149 ++++++++++++++++-- .../src/lib/services/app-config-service.ts | 7 + .../components/client-provider-images.svelte | 2 +- .../application-configuration/+page.svelte | 22 ++- .../application-image.svelte | 11 +- .../update-application-images.svelte | 25 ++- .../apps/authorized-oidc-client-card.svelte | 21 ++- tests/specs/application-configuration.spec.ts | 45 ++++-- 16 files changed, 367 insertions(+), 87 deletions(-) delete mode 100644 backend/resources/images/logo.svg delete mode 100644 backend/resources/images/logoDark.svg delete mode 100644 backend/resources/images/logoLight.svg diff --git a/README.md b/README.md index 5f3fd394..1d7538f1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -#

Pocket ID
+#

Pocket ID
Pocket ID is an easy-to-use OpenID Connect Certified™ and OAuth 2.0 provider that lets users sign in to your applications with passkeys. diff --git a/backend/internal/bootstrap/app_images_bootstrap.go b/backend/internal/bootstrap/app_images_bootstrap.go index 84f414ea..76729083 100644 --- a/backend/internal/bootstrap/app_images_bootstrap.go +++ b/backend/internal/bootstrap/app_images_bootstrap.go @@ -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 { diff --git a/backend/internal/controller/app_images_controller.go b/backend/internal/controller/app_images_controller.go index fd759953..22866345 100644 --- a/backend/internal/controller/app_images_controller.go +++ b/backend/internal/controller/app_images_controller.go @@ -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 diff --git a/backend/internal/service/app_images_service.go b/backend/internal/service/app_images_service.go index c7df9b95..57a10a1f 100644 --- a/backend/internal/service/app_images_service.go +++ b/backend/internal/service/app_images_service.go @@ -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() diff --git a/backend/internal/service/app_images_service_test.go b/backend/internal/service/app_images_service_test.go index 96cfa4bf..07592f02 100644 --- a/backend/internal/service/app_images_service_test.go +++ b/backend/internal/service/app_images_service_test.go @@ -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) }) } diff --git a/backend/resources/images/logo.svg b/backend/resources/images/logo.svg deleted file mode 100644 index 0ee89b14..00000000 --- a/backend/resources/images/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/resources/images/logoDark.svg b/backend/resources/images/logoDark.svg deleted file mode 100644 index 6ee3cae3..00000000 --- a/backend/resources/images/logoDark.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/resources/images/logoLight.svg b/backend/resources/images/logoLight.svg deleted file mode 100644 index 8194f043..00000000 --- a/backend/resources/images/logoLight.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/lib/components/logo.svelte b/frontend/src/lib/components/logo.svelte index bbcc35ef..0f736367 100644 --- a/frontend/src/lib/components/logo.svelte +++ b/frontend/src/lib/components/logo.svelte @@ -1,18 +1,149 @@ + + -{m.logo()} +{#if useDefaultLogo} + + + {#if animateDefaultLogo} + + + + {/if} + + +{:else} + {logoAlt} +{/if} diff --git a/frontend/src/lib/services/app-config-service.ts b/frontend/src/lib/services/app-config-service.ts index 6a66bad1..859c1b20 100644 --- a/frontend/src/lib/services/app-config-service.ts +++ b/frontend/src/lib/services/app-config-service.ts @@ -47,6 +47,13 @@ export default class AppConfigService extends APIService { cachedApplicationLogo.bustCache(light); }; + deleteLogo = async (light = true) => { + await this.api.delete(`/application-images/logo`, { + params: { light } + }); + cachedApplicationLogo.bustCache(light); + }; + updateEmailLogo = async (emailLogo: File) => { const formData = new FormData(); formData.append('file', emailLogo); diff --git a/frontend/src/routes/authorize/components/client-provider-images.svelte b/frontend/src/routes/authorize/components/client-provider-images.svelte index a18db86f..046f6fff 100644 --- a/frontend/src/routes/authorize/components/client-provider-images.svelte +++ b/frontend/src/routes/authorize/components/client-provider-images.svelte @@ -37,7 +37,7 @@ ? 'translate-x-[108px]' : ''}" > - + - + {#if fallback} + {@render fallback()} + {:else} + + {/if} {:else} { e.stopPropagation(); onReset(); 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 df8e8a94..c0b2448d 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 @@ -1,4 +1,5 @@ +{#snippet lightLogoFallback()} + +{/snippet} + +{#snippet darkLogoFallback()} + +{/snippet} +
import { goto } from '$app/navigation'; import ImageBox from '$lib/components/image-box.svelte'; + import Logo from '$lib/components/logo.svelte'; import { Button } from '$lib/components/ui/button'; import * as Card from '$lib/components/ui/card'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; @@ -8,7 +9,7 @@ import { m } from '$lib/paraglide/messages'; import userStore from '$lib/stores/user-store'; import type { AccessibleOidcClient, OidcClientMetaData } from '$lib/types/oidc.type'; - import { cachedApplicationLogo, cachedOidcClientLogo } from '$lib/utils/cached-image-util'; + import { cachedOidcClientLogo } from '$lib/utils/cached-image-util'; import { encodeClientIdParam } from '$lib/utils/client-id-util'; import { LucideBan, @@ -38,13 +39,17 @@
- + {#if client.hasLogo} + + {:else} +
+ +
+ {/if}
diff --git a/tests/specs/application-configuration.spec.ts b/tests/specs/application-configuration.spec.ts index d9bae7d0..70023893 100644 --- a/tests/specs/application-configuration.spec.ts +++ b/tests/specs/application-configuration.spec.ts @@ -177,17 +177,25 @@ test('Update email configuration', async ({ page }) => { }); test.describe('Update application images', () => { - test('should upload images', async ({ page }) => { - await page.getByLabel('Favicon').setInputFiles('resources/images/w3-schools-favicon.ico'); + test('should upload images and reset custom logos', async ({ page }) => { await page - .getByLabel('Light Mode Logo') - .setInputFiles('resources/images/pingvin-share-logo.png'); - await page.getByLabel('Dark Mode Logo').setInputFiles('resources/images/cloud-logo.png'); - await page.getByLabel('Email Logo').setInputFiles('resources/images/pingvin-share-logo.png'); + .getByLabel('Favicon', { exact: true }) + .setInputFiles('resources/images/w3-schools-favicon.ico'); await page - .getByLabel('Default Profile Picture') + .getByLabel('Light Mode Logo', { exact: true }) .setInputFiles('resources/images/pingvin-share-logo.png'); - await page.getByLabel('Background Image').setInputFiles('resources/images/clouds.jpg'); + await page + .getByLabel('Dark Mode Logo', { exact: true }) + .setInputFiles('resources/images/cloud-logo.png'); + await page + .getByLabel('Email Logo', { exact: true }) + .setInputFiles('resources/images/pingvin-share-logo.png'); + await page + .getByLabel('Default Profile Picture', { exact: true }) + .setInputFiles('resources/images/pingvin-share-logo.png'); + await page + .getByLabel('Background Image', { exact: true }) + .setInputFiles('resources/images/clouds.jpg'); await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click(); await expect(page.locator('[data-type="success"]')).toHaveText( @@ -209,10 +217,29 @@ test.describe('Update application images', () => { await page.request .get('/api/application-images/background') .then((res) => expect.soft(res.status()).toBe(200)); + + await page + .getByRole('button', { name: 'Reset to default Light Mode Logo', exact: true }) + .click(); + await page + .getByRole('button', { name: 'Reset to default Dark Mode Logo', exact: true }) + .click(); + await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click(); + + await expect(page.locator('[data-type="success"]')).toHaveText( + 'Images updated successfully. It may take a few minutes to update.' + ); + + await page.request + .get('/api/application-images/logo?light=true') + .then((res) => expect.soft(res.status()).toBe(404)); + await page.request + .get('/api/application-images/logo?light=false') + .then((res) => expect.soft(res.status()).toBe(404)); }); test('should only allow png/jpeg for email logo', async ({ page }) => { - const emailLogoInput = page.getByLabel('Email Logo'); + const emailLogoInput = page.getByLabel('Email Logo', { exact: true }); await emailLogoInput.setInputFiles('resources/images/cloud-logo.svg'); await page.getByRole('button', { name: 'Save', exact: true }).nth(1).click();