fix: serve bundled logo if no custom logo is set (#1748)

This commit is contained in:
Alessandro (Ale) Segala
2026-09-14 11:33:31 +02:00
committed by GitHub
parent 41eb35b8a7
commit de2780bbef
9 changed files with 217 additions and 32 deletions
@@ -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
}
@@ -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
}
@@ -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)
@@ -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), "<svg"))
})
t.Run("returns not found if no embedded image exists", func(t *testing.T) {
_, _, _, err := service.GetImageWithDefault(t.Context(), "default-profile-picture")
require.Error(t, err)
assert.True(t, apperror.IsCode(err, apperror.CodeImageNotFound))
})
t.Run("GetImage doesn't return the embedded image", func(t *testing.T) {
_, _, _, err := service.GetImage(t.Context(), "logoLight")
require.Error(t, err)
assert.True(t, apperror.IsCode(err, apperror.CodeImageNotFound))
})
}
func TestAppImagesService_UpdateImage(t *testing.T) {
store := newFilesystemStorageForTest(t)
require.NoError(t, store.Save(context.Background(), path.Join("application-images", "logoLight.svg"), bytes.NewReader([]byte("old"))))
err := store.Save(t.Context(), path.Join("application-images", "logoLight.svg"), bytes.NewReader([]byte("old")))
require.NoError(t, err)
service := NewAppImagesService(map[string]string{"logoLight": "svg"}, store)
fileHeader := newFileHeader(t, "logoLight.png", []byte("new"))
require.NoError(t, service.UpdateImage(context.Background(), fileHeader, "logoLight"))
err = service.UpdateImage(t.Context(), fileHeader, "logoLight")
require.NoError(t, err)
reader, _, err := store.Open(context.Background(), path.Join("application-images", "logoLight.png"))
reader, _, err := store.Open(t.Context(), path.Join("application-images", "logoLight.png"))
require.NoError(t, err)
_ = reader.Close()
_, _, err = store.Open(context.Background(), path.Join("application-images", "logoLight.svg"))
_, _, err = store.Open(t.Context(), path.Join("application-images", "logoLight.svg"))
require.ErrorIs(t, err, fs.ErrNotExist)
}
@@ -65,9 +111,10 @@ func TestAppImagesService_UpdateImageStripsMetadata(t *testing.T) {
webpChunk("EXIF", []byte("secret")),
))
require.NoError(t, service.UpdateImage(context.Background(), fileHeader, "logoLight"))
err := service.UpdateImage(t.Context(), fileHeader, "logoLight")
require.NoError(t, err)
reader, _, err := store.Open(context.Background(), path.Join("application-images", "logoLight.webp"))
reader, _, err := store.Open(t.Context(), path.Join("application-images", "logoLight.webp"))
require.NoError(t, err)
defer reader.Close()
@@ -83,34 +130,37 @@ func TestAppImagesService_ErrorsAndFlags(t *testing.T) {
service := NewAppImagesService(map[string]string{}, store)
t.Run("get missing image returns not found", func(t *testing.T) {
_, _, _, err := service.GetImage(context.Background(), "missing")
_, _, _, err := service.GetImage(t.Context(), "missing")
require.Error(t, err)
assert.True(t, apperror.IsCode(err, apperror.CodeImageNotFound))
})
t.Run("reject unsupported file types", func(t *testing.T) {
err := service.UpdateImage(context.Background(), newFileHeader(t, "logo.txt", []byte("nope")), "logo")
err := service.UpdateImage(t.Context(), newFileHeader(t, "logo.txt", []byte("nope")), "logo")
require.Error(t, err)
assert.True(t, apperror.IsCode(err, apperror.CodeFileTypeNotSupported))
})
t.Run("delete and extension tracking", func(t *testing.T) {
require.NoError(t, store.Save(context.Background(), path.Join("application-images", "default-profile-picture.png"), bytes.NewReader([]byte("img"))))
err := store.Save(t.Context(), path.Join("application-images", "default-profile-picture.png"), bytes.NewReader([]byte("img")))
require.NoError(t, err)
service.extensions["default-profile-picture"] = "png"
require.NoError(t, service.DeleteImage(context.Background(), "default-profile-picture"))
err = service.DeleteImage(t.Context(), "default-profile-picture")
require.NoError(t, err)
assert.False(t, service.IsDefaultProfilePictureSet())
reader, size, err := store.Open(context.Background(), deletedApplicationImagePath("default-profile-picture"))
reader, size, err := store.Open(t.Context(), 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(t.Context(), "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"))
err = service.UpdateImage(t.Context(), newFileHeader(t, "default-profile-picture.png", []byte("new")), "default-profile-picture")
require.NoError(t, err)
_, _, err = store.Open(t.Context(), deletedApplicationImagePath("default-profile-picture"))
require.ErrorIs(t, err, fs.ErrNotExist)
})
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-346.211 -706.48 712.96 712.96"><path fill="#fff" d="M-250.368,-706.48C-166.912,-706.48 -83.456,-706.48 0,-706.48C149.377,-706.48 270.906,-584.953 270.906,-435.576C270.906,-376.876 252.438,-321.028 217.506,-274.062C183.258,-228.019 136.385,-194.563 81.955,-177.305C76.939,-175.715 71.924,-174.124 66.908,-172.534C54.955,-231.481 43.003,-290.429 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-50.48,-230.815 -69.733,-112.167 -88.985,6.48C-142.779,6.48 -196.574,6.48 -250.368,6.48Z"/></svg>

After

Width:  |  Height:  |  Size: 793 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-346.211 -706.48 712.96 712.96"><path fill="#000" d="M-250.368,-706.48C-166.912,-706.48 -83.456,-706.48 0,-706.48C149.377,-706.48 270.906,-584.953 270.906,-435.576C270.906,-376.876 252.438,-321.028 217.506,-274.062C183.258,-228.019 136.385,-194.563 81.955,-177.305C76.939,-175.715 71.924,-174.124 66.908,-172.534C54.955,-231.481 43.003,-290.429 31.05,-349.376C34.355,-350.974 37.661,-352.571 40.966,-354.169C73.345,-369.822 94.269,-403.156 94.269,-439.094C94.269,-491.073 51.982,-533.36 0,-533.36C-51.978,-533.36 -94.267,-491.073 -94.267,-439.094C-94.267,-403.156 -73.344,-369.822 -40.963,-354.169C-37.718,-352.6 -34.473,-351.032 -31.228,-349.463C-50.48,-230.815 -69.733,-112.167 -88.985,6.48C-142.779,6.48 -196.574,6.48 -250.368,6.48Z"/></svg>

After

Width:  |  Height:  |  Size: 793 B

+1 -1
View File
@@ -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
+10 -10
View File
@@ -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))
+10 -1
View File
@@ -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));
});