diff --git a/backend/internal/controller/app_images_controller.go b/backend/internal/controller/app_images_controller.go index 133b47f9..9f506b64 100644 --- a/backend/internal/controller/app_images_controller.go +++ b/backend/internal/controller/app_images_controller.go @@ -3,11 +3,11 @@ package controller import ( "net/http" "slices" - "strconv" "strings" "time" "github.com/gin-gonic/gin" + kitutils "github.com/italypaleale/go-kit/utils" "github.com/pocket-id/pocket-id/backend/internal/apperror" _ "github.com/pocket-id/pocket-id/backend/internal/dto" @@ -52,6 +52,7 @@ type AppImagesController struct { // @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)" +// @Param default query boolean false "Return the bundled default logo if no custom logo is set (default true)" // @Produce image/png // @Produce image/jpeg // @Produce image/svg+xml @@ -155,7 +156,7 @@ func (c *AppImagesController) deleteLogoHandler(ctx *gin.Context) error { } func logoImageName(ctx *gin.Context) string { - lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true")) + lightLogo := kitutils.IsTruthy(ctx.DefaultQuery("light", "true")) if lightLogo { return "logoLight" } @@ -261,7 +262,12 @@ func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) error { } func (c *AppImagesController) getImage(ctx *gin.Context, name string) error { - reader, size, mimeType, err := c.appImagesService.GetImage(ctx.Request.Context(), name) + getImage := c.appImagesService.GetImage + if kitutils.IsTruthy(ctx.DefaultQuery("default", "true")) { + getImage = c.appImagesService.GetImageWithDefault + } + + reader, size, mimeType, err := getImage(ctx.Request.Context(), name) if err != nil { return err } diff --git a/backend/internal/controller/app_images_controller_test.go b/backend/internal/controller/app_images_controller_test.go new file mode 100644 index 00000000..f3c8867a --- /dev/null +++ b/backend/internal/controller/app_images_controller_test.go @@ -0,0 +1,81 @@ +package controller + +import ( + "bytes" + "net/http" + "net/http/httptest" + "path" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/apperror" + "github.com/pocket-id/pocket-id/backend/internal/service" + "github.com/pocket-id/pocket-id/backend/internal/storage" +) + +func TestAppImagesControllerGetLogo(t *testing.T) { + gin.SetMode(gin.TestMode) + + store, err := storage.NewFilesystemStorage(t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, store.Close()) + }) + + extensions := map[string]string{} + appImagesController := &AppImagesController{ + appImagesService: service.NewAppImagesService(extensions, store), + } + + t.Run("returns the bundled logo if no custom logo is set", func(t *testing.T) { + res, err := getLogo(t, appImagesController, "/api/application-images/logo") + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.Code) + assert.Equal(t, "image/svg+xml", res.Header().Get("Content-Type")) + assert.Contains(t, res.Body.String(), `fill="#000"`) + }) + + t.Run("returns the bundled dark mode logo if no custom logo is set", func(t *testing.T) { + res, err := getLogo(t, appImagesController, "/api/application-images/logo?light=false") + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.Code) + assert.Contains(t, res.Body.String(), `fill="#fff"`) + }) + + t.Run("returns not found if the bundled logo is skipped", func(t *testing.T) { + _, err := getLogo(t, appImagesController, "/api/application-images/logo?default=false") + require.Error(t, err) + assert.True(t, apperror.IsCode(err, apperror.CodeImageNotFound)) + }) + + t.Run("returns the custom logo if one is set", func(t *testing.T) { + err := store.Save(t.Context(), path.Join("application-images", "logoLight.png"), bytes.NewReader([]byte("custom"))) + require.NoError(t, err) + extensions["logoLight"] = "png" + t.Cleanup(func() { + delete(extensions, "logoLight") + }) + + for _, target := range []string{"/api/application-images/logo", "/api/application-images/logo?default=false"} { + res, err := getLogo(t, appImagesController, target) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, res.Code) + assert.Equal(t, "image/png", res.Header().Get("Content-Type")) + assert.Equal(t, "custom", res.Body.String()) + } + }) +} + +func getLogo(t *testing.T, appImagesController *AppImagesController, target string) (*httptest.ResponseRecorder, error) { + t.Helper() + + res := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(res) + ctx.Request = httptest.NewRequestWithContext(t.Context(), http.MethodGet, target, http.NoBody) + + err := appImagesController.getLogoHandler(ctx) + return res, err +} diff --git a/backend/internal/service/app_images_service.go b/backend/internal/service/app_images_service.go index 57a10a1f..f93918f9 100644 --- a/backend/internal/service/app_images_service.go +++ b/backend/internal/service/app_images_service.go @@ -13,6 +13,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" imageutil "github.com/pocket-id/pocket-id/backend/internal/utils/image" + "github.com/pocket-id/pocket-id/backend/resources" ) type AppImagesService struct { @@ -47,6 +48,42 @@ func (s *AppImagesService) GetImage(ctx context.Context, name string) (io.ReadCl return reader, size, mimeType, nil } +// GetImageWithDefault behaves like GetImage, but falls back to the image embedded in the binary if no custom image has been uploaded +func (s *AppImagesService) GetImageWithDefault(ctx context.Context, name string) (f io.ReadCloser, size int64, mimeType string, err error) { + f, size, mimeType, err = s.GetImage(ctx, name) + if err == nil || !apperror.IsCode(err, apperror.CodeImageNotFound) { + return f, size, mimeType, err + } + + return getDefaultImage(name) +} + +func getDefaultImage(name string) (io.ReadCloser, int64, string, error) { + // Map an image name to an image embedded in the binary + var imagePath string + switch name { + case "logoLight": + imagePath = "default-images/logoLight.svg" + case "logoDark": + imagePath = "default-images/logoDark.svg" + default: + return nil, 0, "", apperror.ImageNotFound() + } + + file, err := resources.FS.Open(imagePath) + if err != nil { + return nil, 0, "", fmt.Errorf("failed to open default image '%s': %w", name, err) + } + + stat, err := file.Stat() + if err != nil { + file.Close() + return nil, 0, "", fmt.Errorf("failed to get size of default image '%s': %w", name, err) + } + + return file, stat.Size(), utils.GetImageMimeType(utils.GetFileExtension(imagePath)), nil +} + func (s *AppImagesService) UpdateImage(ctx context.Context, file *multipart.FileHeader, imageName string) error { fileType := strings.ToLower(utils.GetFileExtension(file.Filename)) mimeType := utils.GetImageMimeType(fileType) diff --git a/backend/internal/service/app_images_service_test.go b/backend/internal/service/app_images_service_test.go index 07592f02..0b01e1e3 100644 --- a/backend/internal/service/app_images_service_test.go +++ b/backend/internal/service/app_images_service_test.go @@ -2,7 +2,6 @@ package service import ( "bytes" - "context" "encoding/binary" "io" "io/fs" @@ -10,6 +9,7 @@ import ( "net/http" "net/http/httptest" "path" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -22,11 +22,12 @@ import ( func TestAppImagesService_GetImage(t *testing.T) { store := newFilesystemStorageForTest(t) - require.NoError(t, store.Save(context.Background(), path.Join("application-images", "background.webp"), bytes.NewReader([]byte("data")))) + err := store.Save(t.Context(), path.Join("application-images", "background.webp"), bytes.NewReader([]byte("data"))) + require.NoError(t, err) service := NewAppImagesService(map[string]string{"background": "webp"}, store) - reader, size, mimeType, err := service.GetImage(context.Background(), "background") + reader, size, mimeType, err := service.GetImage(t.Context(), "background") require.NoError(t, err) defer reader.Close() payload, err := io.ReadAll(reader) @@ -36,22 +37,67 @@ func TestAppImagesService_GetImage(t *testing.T) { require.Equal(t, "image/webp", mimeType) } +func TestAppImagesService_GetImageWithDefault(t *testing.T) { + store := newFilesystemStorageForTest(t) + + err := store.Save(t.Context(), path.Join("application-images", "logoDark.png"), bytes.NewReader([]byte("custom"))) + require.NoError(t, err) + + service := NewAppImagesService(map[string]string{"logoDark": "png"}, store) + + t.Run("returns the custom image if one is set", func(t *testing.T) { + reader, size, mimeType, err := service.GetImageWithDefault(t.Context(), "logoDark") + require.NoError(t, err) + defer reader.Close() + payload, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, []byte("custom"), payload) + assert.Equal(t, int64(len(payload)), size) + assert.Equal(t, "image/png", mimeType) + }) + + t.Run("returns the embedded image if no custom image is set", func(t *testing.T) { + reader, size, mimeType, err := service.GetImageWithDefault(t.Context(), "logoLight") + require.NoError(t, err) + defer reader.Close() + payload, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, int64(len(payload)), size) + assert.Equal(t, "image/svg+xml", mimeType) + assert.True(t, strings.HasPrefix(string(payload), " \ No newline at end of file diff --git a/backend/resources/default-images/logoLight.svg b/backend/resources/default-images/logoLight.svg new file mode 100644 index 00000000..6f9779a1 --- /dev/null +++ b/backend/resources/default-images/logoLight.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/resources/files.go b/backend/resources/files.go index e55ecc28..ecd5bd08 100644 --- a/backend/resources/files.go +++ b/backend/resources/files.go @@ -4,5 +4,5 @@ import "embed" // Embedded file systems for the project -//go:embed email-templates/*.tmpl images migrations fonts aaguids.json +//go:embed email-templates/*.tmpl images default-images migrations fonts aaguids.json var FS embed.FS diff --git a/frontend/src/lib/utils/cached-image-util.ts b/frontend/src/lib/utils/cached-image-util.ts index 61032335..e547c8c7 100644 --- a/frontend/src/lib/utils/cached-image-util.ts +++ b/frontend/src/lib/utils/cached-image-util.ts @@ -10,18 +10,18 @@ type CachableImage = { }; export const cachedApplicationLogo: CachableImage = { - getUrl: (light = true) => { - const url = new URL('/api/application-images/logo', window.location.origin); - if (!light) url.searchParams.set('light', 'false'); - return getCachedImageUrl(url); - }, - bustCache: (light = true) => { - const url = new URL('/api/application-images/logo', window.location.origin); - if (!light) url.searchParams.set('light', 'false'); - bustImageCache(url); - } + getUrl: (light = true) => getCachedImageUrl(applicationLogoUrl(light)), + bustCache: (light = true) => bustImageCache(applicationLogoUrl(light)) }; +// The UI renders its own default logo, so the bundled logo is skipped to be able to tell whether a custom logo has been uploaded +function applicationLogoUrl(light: boolean) { + const url = new URL('/api/application-images/logo', window.location.origin); + if (!light) url.searchParams.set('light', 'false'); + url.searchParams.set('default', 'false'); + return url; +} + export const cachedEmailLogo: CachableImage = { getUrl: () => getCachedImageUrl(new URL('/api/application-images/email', window.location.origin)), bustCache: () => bustImageCache(new URL('/api/application-images/email', window.location.origin)) diff --git a/tests/specs/application-configuration.spec.ts b/tests/specs/application-configuration.spec.ts index 4e733ceb..39a501d7 100644 --- a/tests/specs/application-configuration.spec.ts +++ b/tests/specs/application-configuration.spec.ts @@ -243,11 +243,20 @@ test.describe('Update application images', () => { 'Images updated successfully. It may take a few minutes to update.' ); + // Without a custom logo the endpoint falls back to the logo bundled with Pocket ID await page.request .get('/api/application-images/logo?light=true') - .then((res) => expect.soft(res.status()).toBe(404)); + .then((res) => expect.soft(res.status()).toBe(200)); await page.request .get('/api/application-images/logo?light=false') + .then((res) => expect.soft(res.status()).toBe(200)); + + // The bundled logo can be skipped to check whether a custom logo is set + await page.request + .get('/api/application-images/logo?light=true&default=false') + .then((res) => expect.soft(res.status()).toBe(404)); + await page.request + .get('/api/application-images/logo?light=false&default=false') .then((res) => expect.soft(res.status()).toBe(404)); });