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));
});