diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 30ac1b94..9153bae9 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -198,6 +198,7 @@ func initLogger(r *gin.Engine) { "GET /api/application-images/logo", "GET /api/application-images/background", "GET /api/application-images/favicon", + "GET /api/application-images/email", "GET /_app", "GET /fonts", "GET /healthz", diff --git a/backend/internal/controller/app_images_controller.go b/backend/internal/controller/app_images_controller.go index c4568377..ec3215aa 100644 --- a/backend/internal/controller/app_images_controller.go +++ b/backend/internal/controller/app_images_controller.go @@ -23,11 +23,13 @@ func NewAppImagesController( } group.GET("/application-images/logo", controller.getLogoHandler) + group.GET("/application-images/email", controller.getEmailLogoHandler) group.GET("/application-images/background", controller.getBackgroundImageHandler) group.GET("/application-images/favicon", controller.getFaviconHandler) group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture) group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler) + group.PUT("/application-images/email", authMiddleware.Add(), controller.updateEmailLogoHandler) group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler) group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler) group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture) @@ -59,6 +61,18 @@ func (c *AppImagesController) getLogoHandler(ctx *gin.Context) { c.getImage(ctx, imageName) } +// getEmailLogoHandler godoc +// @Summary Get email logo image +// @Description Get the email logo image for use in emails +// @Tags Application Images +// @Produce image/png +// @Produce image/jpeg +// @Success 200 {file} binary "Email logo image" +// @Router /api/application-images/email [get] +func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) { + c.getImage(ctx, "logoEmail") +} + // getBackgroundImageHandler godoc // @Summary Get background image // @Description Get the background image for the application @@ -124,6 +138,37 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) { ctx.Status(http.StatusNoContent) } +// updateEmailLogoHandler godoc +// @Summary Update email logo +// @Description Update the email logo for use in emails +// @Tags Application Images +// @Accept multipart/form-data +// @Param file formData file true "Email logo image file" +// @Success 204 "No Content" +// @Router /api/application-images/email [put] +func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) { + file, err := ctx.FormFile("file") + if err != nil { + _ = ctx.Error(err) + return + } + + fileType := utils.GetFileExtension(file.Filename) + mimeType := utils.GetImageMimeType(fileType) + + if mimeType != "image/png" && mimeType != "image/jpeg" { + _ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"}) + return + } + + if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil { + _ = ctx.Error(err) + return + } + + ctx.Status(http.StatusNoContent) +} + // updateBackgroundImageHandler godoc // @Summary Update background image // @Description Update the application background image diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 3f0a7e30..05affa5b 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -78,7 +78,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr data := &email.TemplateData[V]{ AppName: dbConfig.AppName.Value, - LogoURL: common.EnvConfig.AppURL + "/api/application-images/logo", + LogoURL: common.EnvConfig.AppURL + "/api/application-images/email", Data: tData, } diff --git a/backend/resources/images/logoEmail.png b/backend/resources/images/logoEmail.png new file mode 100644 index 00000000..ab7bff23 Binary files /dev/null and b/backend/resources/images/logoEmail.png differ diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 9ec15916..12f285e6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -311,6 +311,7 @@ "favicon": "Favicon", "light_mode_logo": "Light Mode Logo", "dark_mode_logo": "Dark Mode Logo", + "email_logo": "Email Logo", "background_image": "Background Image", "language": "Language", "reset_profile_picture_question": "Reset profile picture?", diff --git a/frontend/src/lib/services/app-config-service.ts b/frontend/src/lib/services/app-config-service.ts index 1e8e7f64..88386bf6 100644 --- a/frontend/src/lib/services/app-config-service.ts +++ b/frontend/src/lib/services/app-config-service.ts @@ -4,6 +4,7 @@ import { cachedApplicationLogo, cachedBackgroundImage, cachedDefaultProfilePicture, + cachedEmailLogo, cachedProfilePicture } from '$lib/utils/cached-image-util'; import { get } from 'svelte/store'; @@ -46,6 +47,14 @@ export default class AppConfigService extends APIService { cachedApplicationLogo.bustCache(light); }; + updateEmailLogo = async (emailLogo: File) => { + const formData = new FormData(); + formData.append('file', emailLogo); + + await this.api.put(`/application-images/email`, formData); + cachedEmailLogo.bustCache(); + }; + updateDefaultProfilePicture = async (defaultProfilePicture: File) => { const formData = new FormData(); formData.append('file', defaultProfilePicture); diff --git a/frontend/src/lib/utils/cached-image-util.ts b/frontend/src/lib/utils/cached-image-util.ts index bb732bd1..4f883867 100644 --- a/frontend/src/lib/utils/cached-image-util.ts +++ b/frontend/src/lib/utils/cached-image-util.ts @@ -20,6 +20,11 @@ export const cachedApplicationLogo: CachableImage = { } }; +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)) +}; + export const cachedDefaultProfilePicture: CachableImage = { getUrl: () => getCachedImageUrl( diff --git a/frontend/src/routes/settings/admin/application-configuration/+page.svelte b/frontend/src/routes/settings/admin/application-configuration/+page.svelte index 05566b01..fdecb945 100644 --- a/frontend/src/routes/settings/admin/application-configuration/+page.svelte +++ b/frontend/src/routes/settings/admin/application-configuration/+page.svelte @@ -42,6 +42,7 @@ async function updateImages( logoLight: File | undefined, logoDark: File | undefined, + logoEmail: File | undefined, defaultProfilePicture: File | null | undefined, backgroundImage: File | undefined, favicon: File | undefined @@ -56,6 +57,10 @@ ? appConfigService.updateLogo(logoDark, false) : Promise.resolve(); + const emailLogoPromise = logoEmail + ? appConfigService.updateEmailLogo(logoEmail) + : Promise.resolve(); + const defaultProfilePicturePromise = defaultProfilePicture === null ? appConfigService.deleteDefaultProfilePicture() @@ -70,6 +75,7 @@ await Promise.all([ lightLogoPromise, darkLogoPromise, + emailLogoPromise, defaultProfilePicturePromise, backgroundImagePromise, faviconPromise 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 8fced2f7..e59795ee 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 @@ -4,7 +4,8 @@ import { cachedApplicationLogo, cachedBackgroundImage, - cachedDefaultProfilePicture + cachedDefaultProfilePicture, + cachedEmailLogo } from '$lib/utils/cached-image-util'; import ApplicationImage from './application-image.svelte'; @@ -14,6 +15,7 @@ callback: ( logoLight: File | undefined, logoDark: File | undefined, + logoEmail: File | undefined, defaultProfilePicture: File | null | undefined, backgroundImage: File | undefined, favicon: File | undefined @@ -22,6 +24,7 @@ let logoLight = $state(); let logoDark = $state(); + let logoEmail = $state(); let defaultProfilePicture = $state(); let backgroundImage = $state(); let favicon = $state(); @@ -54,6 +57,15 @@ imageURL={cachedApplicationLogo.getUrl(false)} forceColorScheme="dark" /> + callback(logoLight, logoDark, defaultProfilePicture, backgroundImage, favicon)} + onclick={() => + callback(logoLight, logoDark, logoEmail, defaultProfilePicture, backgroundImage, favicon)} >{m.save()} diff --git a/tests/assets/cloud-logo.png b/tests/assets/cloud-logo.png new file mode 100644 index 00000000..cb1a8e0f Binary files /dev/null and b/tests/assets/cloud-logo.png differ diff --git a/tests/assets/cloud-logo.svg b/tests/assets/cloud-logo.svg new file mode 100644 index 00000000..1b6ea276 --- /dev/null +++ b/tests/assets/cloud-logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/tests/assets/nextcloud-logo.png b/tests/assets/nextcloud-logo.png deleted file mode 100644 index 5d4e8e06..00000000 Binary files a/tests/assets/nextcloud-logo.png and /dev/null differ diff --git a/tests/specs/application-configuration.spec.ts b/tests/specs/application-configuration.spec.ts index a675a60b..816e3f0a 100644 --- a/tests/specs/application-configuration.spec.ts +++ b/tests/specs/application-configuration.spec.ts @@ -116,30 +116,49 @@ test('Update email configuration', async ({ page }) => { await expect(page.getByLabel('API Key Expiration')).toBeChecked(); }); -test('Update application images', async ({ page }) => { - await page.getByRole('button', { name: 'Expand card' }).nth(4).click(); +test.describe('Update application images', () => { + test.beforeEach(async ({ page }) => { + await page.getByRole('button', { name: 'Expand card' }).nth(4).click(); + }); - await page.getByLabel('Favicon').setInputFiles('assets/w3-schools-favicon.ico'); - await page.getByLabel('Light Mode Logo').setInputFiles('assets/pingvin-share-logo.png'); - await page.getByLabel('Dark Mode Logo').setInputFiles('assets/nextcloud-logo.png'); - await page.getByLabel('Default Profile Picture').setInputFiles('assets/pingvin-share-logo.png'); - await page.getByLabel('Background Image').setInputFiles('assets/clouds.jpg'); - await page.getByRole('button', { name: 'Save' }).last().click(); + test('should upload images', async ({ page }) => { + await page.getByLabel('Favicon').setInputFiles('assets/w3-schools-favicon.ico'); + await page.getByLabel('Light Mode Logo').setInputFiles('assets/pingvin-share-logo.png'); + await page.getByLabel('Dark Mode Logo').setInputFiles('assets/cloud-logo.png'); + await page.getByLabel('Email Logo').setInputFiles('assets/pingvin-share-logo.png'); + await page.getByLabel('Default Profile Picture').setInputFiles('assets/pingvin-share-logo.png'); + await page.getByLabel('Background Image').setInputFiles('assets/clouds.jpg'); + await page.getByRole('button', { name: 'Save' }).last().click(); - await expect(page.locator('[data-type="success"]')).toHaveText( - 'Images updated successfully. It may take a few minutes to update.' - ); + 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/favicon') - .then((res) => expect.soft(res.status()).toBe(200)); - await page.request - .get('/api/application-images/logo?light=true') - .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)); - await page.request - .get('/api/application-images/background') - .then((res) => expect.soft(res.status()).toBe(200)); -}); + await page.request + .get('/api/application-images/favicon') + .then((res) => expect.soft(res.status()).toBe(200)); + await page.request + .get('/api/application-images/logo?light=true') + .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)); + await page.request + .get('/api/application-images/email') + .then((res) => expect.soft(res.status()).toBe(200)); + await page.request + .get('/api/application-images/background') + .then((res) => expect.soft(res.status()).toBe(200)); + }); + + test('should only allow png/jpeg for email logo', async ({ page }) => { + const emailLogoInput = page.getByLabel('Email Logo'); + + await emailLogoInput.setInputFiles('assets/cloud-logo.svg'); + await page.getByRole('button', { name: 'Save' }).last().click(); + + await expect(page.locator('[data-type="error"]')).toHaveText( + 'File must be of type .png or .jpg/jpeg' + ); + }); +}); \ No newline at end of file diff --git a/tests/specs/oidc-client-settings.spec.ts b/tests/specs/oidc-client-settings.spec.ts index ed2756c3..5bcd31a2 100644 --- a/tests/specs/oidc-client-settings.spec.ts +++ b/tests/specs/oidc-client-settings.spec.ts @@ -71,9 +71,9 @@ test('Edit OIDC client', async ({ page }) => { await page.getByLabel('Name').fill('Nextcloud updated'); await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback'); await page.locator('[role="tab"][data-value="light-logo"]').first().click(); - await page.setInputFiles('#oidc-client-logo-light', 'assets/nextcloud-logo.png'); + await page.setInputFiles('#oidc-client-logo-light', 'assets/cloud-logo.png'); await page.locator('[role="tab"][data-value="dark-logo"]').first().click(); - await page.setInputFiles('#oidc-client-logo-dark', 'assets/nextcloud-logo.png'); + await page.setInputFiles('#oidc-client-logo-dark', 'assets/cloud-logo.png'); await page.getByLabel('Client Launch URL').fill(oidcClient.launchURL); await page.getByRole('button', { name: 'Save' }).click();