mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-15 17:53:03 +03:00
fix: decouple images from app config service (#965)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
This commit is contained in:
@@ -4,11 +4,9 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
@@ -16,10 +14,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// initApplicationImages copies the images from the images directory to the application-images directory
|
// initApplicationImages copies the images from the images directory to the application-images directory
|
||||||
func initApplicationImages() error {
|
// and returns a map containing the detected file extensions in the application-images directory.
|
||||||
// Images that are built into the Pocket ID binary
|
func initApplicationImages() (map[string]string, error) {
|
||||||
builtInImageHashes := getBuiltInImageHashes()
|
|
||||||
|
|
||||||
// Previous versions of images
|
// Previous versions of images
|
||||||
// If these are found, they are deleted
|
// If these are found, they are deleted
|
||||||
legacyImageHashes := imageHashMap{
|
legacyImageHashes := imageHashMap{
|
||||||
@@ -30,16 +26,20 @@ func initApplicationImages() error {
|
|||||||
|
|
||||||
sourceFiles, err := resources.FS.ReadDir("images")
|
sourceFiles, err := resources.FS.ReadDir("images")
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("failed to read directory: %w", err)
|
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationFiles, err := os.ReadDir(dirPath)
|
destinationFiles, err := os.ReadDir(dirPath)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return fmt.Errorf("failed to read directory: %w", err)
|
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||||
}
|
}
|
||||||
destinationFilesMap := make(map[string]bool, len(destinationFiles))
|
dstNameToExt := make(map[string]string, len(destinationFiles))
|
||||||
for _, f := range destinationFiles {
|
for _, f := range destinationFiles {
|
||||||
|
if f.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
name := f.Name()
|
name := f.Name()
|
||||||
|
nameWithoutExt, ext := utils.SplitFileName(name)
|
||||||
destFilePath := path.Join(dirPath, name)
|
destFilePath := path.Join(dirPath, name)
|
||||||
|
|
||||||
// Skip directories
|
// Skip directories
|
||||||
@@ -58,50 +58,43 @@ func initApplicationImages() error {
|
|||||||
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
|
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
|
||||||
err = os.Remove(destFilePath)
|
err = os.Remove(destFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
|
return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file is a built-in one and save it in the map
|
// Track existing files
|
||||||
destinationFilesMap[getImageNameWithoutExtension(name)] = builtInImageHashes.Contains(h)
|
dstNameToExt[nameWithoutExt] = ext
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy images from the images directory to the application-images directory if they don't already exist
|
// Copy images from the images directory to the application-images directory if they don't already exist
|
||||||
for _, sourceFile := range sourceFiles {
|
for _, sourceFile := range sourceFiles {
|
||||||
// Skip if it's a directory
|
|
||||||
if sourceFile.IsDir() {
|
if sourceFile.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
name := sourceFile.Name()
|
name := sourceFile.Name()
|
||||||
|
nameWithoutExt, ext := utils.SplitFileName(name)
|
||||||
srcFilePath := path.Join("images", name)
|
srcFilePath := path.Join("images", name)
|
||||||
destFilePath := path.Join(dirPath, name)
|
destFilePath := path.Join(dirPath, name)
|
||||||
|
|
||||||
// Skip if there's already an image at the path
|
// Skip if there's already an image at the path
|
||||||
// We do not check the extension because users could have uploaded a different one
|
// We do not check the extension because users could have uploaded a different one
|
||||||
if imageAlreadyExists(sourceFile, destinationFilesMap) {
|
if _, exists := dstNameToExt[nameWithoutExt]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("Writing new application image", slog.String("name", name))
|
slog.Info("Writing new application image", slog.String("name", name))
|
||||||
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
|
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to copy file: %w", err)
|
return nil, fmt.Errorf("failed to copy file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track the newly copied file so it can be included in the extensions map later
|
||||||
|
dstNameToExt[nameWithoutExt] = ext
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return dstNameToExt, nil
|
||||||
}
|
|
||||||
|
|
||||||
func getBuiltInImageHashes() imageHashMap {
|
|
||||||
return imageHashMap{
|
|
||||||
"background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
|
|
||||||
"favicon.ico": mustDecodeHex("70f9c4b6bd4781ade5fc96958b1267511751e91957f83c2354fb880b35ec890a"),
|
|
||||||
"logo.svg": mustDecodeHex("f1e60707df9784152ce0847e3eb59cb68b9015f918ff160376c27ebff1eda796"),
|
|
||||||
"logoDark.svg": mustDecodeHex("0421a8d93714bacf54c78430f1db378fd0d29565f6de59b6a89090d44a82eb16"),
|
|
||||||
"logoLight.svg": mustDecodeHex("6d42c88cf6668f7e57c4f2a505e71ecc8a1e0a27534632aa6adec87b812d0bb0"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type imageHashMap map[string][]byte
|
type imageHashMap map[string][]byte
|
||||||
@@ -118,21 +111,6 @@ func (m imageHashMap) Contains(target []byte) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func imageAlreadyExists(sourceFile fs.DirEntry, destinationFiles map[string]bool) bool {
|
|
||||||
sourceFileWithoutExtension := getImageNameWithoutExtension(sourceFile.Name())
|
|
||||||
_, ok := destinationFiles[sourceFileWithoutExtension]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func getImageNameWithoutExtension(fileName string) string {
|
|
||||||
idx := strings.LastIndexByte(fileName, '.')
|
|
||||||
if idx < 1 {
|
|
||||||
// No dot found, or fileName starts with a dot
|
|
||||||
return fileName
|
|
||||||
}
|
|
||||||
return fileName[:idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustDecodeHex(str string) []byte {
|
func mustDecodeHex(str string) []byte {
|
||||||
b, err := hex.DecodeString(str)
|
b, err := hex.DecodeString(str)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
package bootstrap
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetBuiltInImageData(t *testing.T) {
|
|
||||||
// Get the built-in image data map
|
|
||||||
builtInImages := getBuiltInImageHashes()
|
|
||||||
|
|
||||||
// Read the actual images directory from disk
|
|
||||||
imagesDir := filepath.Join("..", "..", "resources", "images")
|
|
||||||
actualFiles, err := os.ReadDir(imagesDir)
|
|
||||||
require.NoError(t, err, "Failed to read images directory")
|
|
||||||
|
|
||||||
// Create a map of actual files for comparison
|
|
||||||
actualFilesMap := make(map[string]struct{})
|
|
||||||
|
|
||||||
// Validate each actual file exists in the built-in data with correct hash
|
|
||||||
for _, file := range actualFiles {
|
|
||||||
fileName := file.Name()
|
|
||||||
if file.IsDir() || strings.HasPrefix(fileName, ".") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
actualFilesMap[fileName] = struct{}{}
|
|
||||||
|
|
||||||
// Check if the file exists in the built-in data
|
|
||||||
builtInHash, exists := builtInImages[fileName]
|
|
||||||
assert.True(t, exists, "File %s exists in images directory but not in getBuiltInImageData map", fileName)
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath := filepath.Join(imagesDir, fileName)
|
|
||||||
|
|
||||||
// Validate SHA256 hash
|
|
||||||
actualHash, err := utils.CreateSha256FileHash(filePath)
|
|
||||||
require.NoError(t, err, "Failed to compute hash for %s", fileName)
|
|
||||||
assert.Equal(t, actualHash, builtInHash, "SHA256 hash mismatch for file %s", fileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the built-in data doesn't have extra files that don't exist in the directory
|
|
||||||
for fileName := range builtInImages {
|
|
||||||
_, exists := actualFilesMap[fileName]
|
|
||||||
assert.True(t, exists, "File %s exists in getBuiltInImageData map but not in images directory", fileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure we have at least some files (sanity check)
|
|
||||||
assert.NotEmpty(t, actualFilesMap, "Images directory should contain at least one file")
|
|
||||||
assert.Len(t, actualFilesMap, len(builtInImages), "Number of files in directory should match number in built-in data map")
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,7 @@ func Bootstrap(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
slog.InfoContext(ctx, "Pocket ID is starting")
|
slog.InfoContext(ctx, "Pocket ID is starting")
|
||||||
|
|
||||||
err = initApplicationImages()
|
imageExtensions, err := initApplicationImages()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize application images: %w", err)
|
return fmt.Errorf("failed to initialize application images: %w", err)
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,7 @@ func Bootstrap(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create all services
|
// Create all services
|
||||||
svc, err := initServices(ctx, db, httpClient)
|
svc, err := initServices(ctx, db, httpClient, imageExtensions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize services: %w", err)
|
return fmt.Errorf("failed to initialize services: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
|
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
|
||||||
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.appConfigService)
|
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.appConfigService)
|
||||||
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
||||||
|
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||||
@@ -181,9 +182,9 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
|
|
||||||
func initLogger(r *gin.Engine) {
|
func initLogger(r *gin.Engine) {
|
||||||
loggerSkipPathsPrefix := []string{
|
loggerSkipPathsPrefix := []string{
|
||||||
"GET /api/application-configuration/logo",
|
"GET /api/application-images/logo",
|
||||||
"GET /api/application-configuration/background-image",
|
"GET /api/application-images/background",
|
||||||
"GET /api/application-configuration/favicon",
|
"GET /api/application-images/favicon",
|
||||||
"GET /_app",
|
"GET /_app",
|
||||||
"GET /fonts",
|
"GET /fonts",
|
||||||
"GET /healthz",
|
"GET /healthz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
type services struct {
|
type services struct {
|
||||||
appConfigService *service.AppConfigService
|
appConfigService *service.AppConfigService
|
||||||
|
appImagesService *service.AppImagesService
|
||||||
emailService *service.EmailService
|
emailService *service.EmailService
|
||||||
geoLiteService *service.GeoLiteService
|
geoLiteService *service.GeoLiteService
|
||||||
auditLogService *service.AuditLogService
|
auditLogService *service.AuditLogService
|
||||||
@@ -27,7 +28,7 @@ type services struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initializes all services
|
// Initializes all services
|
||||||
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
|
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string) (svc *services, err error) {
|
||||||
svc = &services{}
|
svc = &services{}
|
||||||
|
|
||||||
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
|
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
|
||||||
@@ -35,6 +36,8 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
|
|||||||
return nil, fmt.Errorf("failed to create app config service: %w", err)
|
return nil, fmt.Errorf("failed to create app config service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svc.appImagesService = service.NewAppImagesService(imageExtensions)
|
||||||
|
|
||||||
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
|
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create email service: %w", err)
|
return nil, fmt.Errorf("failed to create email service: %w", err)
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewAppConfigController creates a new controller for application configuration endpoints
|
// NewAppConfigController creates a new controller for application configuration endpoints
|
||||||
@@ -34,13 +32,6 @@ func NewAppConfigController(
|
|||||||
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
|
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
|
||||||
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
|
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
|
||||||
|
|
||||||
group.GET("/application-configuration/logo", acc.getLogoHandler)
|
|
||||||
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
|
|
||||||
group.GET("/application-configuration/favicon", acc.getFaviconHandler)
|
|
||||||
group.PUT("/application-configuration/logo", authMiddleware.Add(), acc.updateLogoHandler)
|
|
||||||
group.PUT("/application-configuration/favicon", authMiddleware.Add(), acc.updateFaviconHandler)
|
|
||||||
group.PUT("/application-configuration/background-image", authMiddleware.Add(), acc.updateBackgroundImageHandler)
|
|
||||||
|
|
||||||
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
|
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
|
||||||
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
|
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
|
||||||
}
|
}
|
||||||
@@ -129,147 +120,6 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, configVariablesDto)
|
c.JSON(http.StatusOK, configVariablesDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLogoHandler godoc
|
|
||||||
// @Summary Get logo image
|
|
||||||
// @Description Get the logo image for the application
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
|
||||||
// @Produce image/png
|
|
||||||
// @Produce image/jpeg
|
|
||||||
// @Produce image/svg+xml
|
|
||||||
// @Success 200 {file} binary "Logo image"
|
|
||||||
// @Router /api/application-configuration/logo [get]
|
|
||||||
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
|
||||||
dbConfig := acc.appConfigService.GetDbConfig()
|
|
||||||
|
|
||||||
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
|
|
||||||
|
|
||||||
var imageName, imageType string
|
|
||||||
if lightLogo {
|
|
||||||
imageName = "logoLight"
|
|
||||||
imageType = dbConfig.LogoLightImageType.Value
|
|
||||||
} else {
|
|
||||||
imageName = "logoDark"
|
|
||||||
imageType = dbConfig.LogoDarkImageType.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.getImage(c, imageName, imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getFaviconHandler godoc
|
|
||||||
// @Summary Get favicon
|
|
||||||
// @Description Get the favicon for the application
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Produce image/x-icon
|
|
||||||
// @Success 200 {file} binary "Favicon image"
|
|
||||||
// @Router /api/application-configuration/favicon [get]
|
|
||||||
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
|
||||||
acc.getImage(c, "favicon", "ico")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBackgroundImageHandler godoc
|
|
||||||
// @Summary Get background image
|
|
||||||
// @Description Get the background image for the application
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Produce image/png
|
|
||||||
// @Produce image/jpeg
|
|
||||||
// @Success 200 {file} binary "Background image"
|
|
||||||
// @Router /api/application-configuration/background-image [get]
|
|
||||||
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
|
|
||||||
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
|
|
||||||
acc.getImage(c, "background", imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateLogoHandler godoc
|
|
||||||
// @Summary Update logo
|
|
||||||
// @Description Update the application logo
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Accept multipart/form-data
|
|
||||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
|
||||||
// @Param file formData file true "Logo image file"
|
|
||||||
// @Success 204 "No Content"
|
|
||||||
// @Router /api/application-configuration/logo [put]
|
|
||||||
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
|
||||||
dbConfig := acc.appConfigService.GetDbConfig()
|
|
||||||
|
|
||||||
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
|
|
||||||
|
|
||||||
var imageName, imageType string
|
|
||||||
if lightLogo {
|
|
||||||
imageName = "logoLight"
|
|
||||||
imageType = dbConfig.LogoLightImageType.Value
|
|
||||||
} else {
|
|
||||||
imageName = "logoDark"
|
|
||||||
imageType = dbConfig.LogoDarkImageType.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.updateImage(c, imageName, imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateFaviconHandler godoc
|
|
||||||
// @Summary Update favicon
|
|
||||||
// @Description Update the application favicon
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Accept multipart/form-data
|
|
||||||
// @Param file formData file true "Favicon file (.ico)"
|
|
||||||
// @Success 204 "No Content"
|
|
||||||
// @Router /api/application-configuration/favicon [put]
|
|
||||||
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
|
||||||
file, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
|
||||||
if fileType != "ico" {
|
|
||||||
_ = c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
acc.updateImage(c, "favicon", "ico")
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateBackgroundImageHandler godoc
|
|
||||||
// @Summary Update background image
|
|
||||||
// @Description Update the application background image
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Accept multipart/form-data
|
|
||||||
// @Param file formData file true "Background image file"
|
|
||||||
// @Success 204 "No Content"
|
|
||||||
// @Router /api/application-configuration/background-image [put]
|
|
||||||
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
|
|
||||||
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
|
|
||||||
acc.updateImage(c, "background", imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getImage is a helper function to serve image files
|
|
||||||
func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) {
|
|
||||||
imagePath := common.EnvConfig.UploadPath + "/application-images/" + name + "." + imageType
|
|
||||||
mimeType := utils.GetImageMimeType(imageType)
|
|
||||||
|
|
||||||
c.Header("Content-Type", mimeType)
|
|
||||||
|
|
||||||
utils.SetCacheControlHeader(c, 15*time.Minute, 24*time.Hour)
|
|
||||||
c.File(imagePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateImage is a helper function to update image files
|
|
||||||
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
|
||||||
file, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = acc.appConfigService.UpdateImage(c.Request.Context(), file, imageName, oldImageType)
|
|
||||||
if err != nil {
|
|
||||||
_ = c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncLdapHandler godoc
|
// syncLdapHandler godoc
|
||||||
// @Summary Synchronize LDAP
|
// @Summary Synchronize LDAP
|
||||||
// @Description Manually trigger LDAP synchronization
|
// @Description Manually trigger LDAP synchronization
|
||||||
|
|||||||
173
backend/internal/controller/app_images_controller.go
Normal file
173
backend/internal/controller/app_images_controller.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAppImagesController(
|
||||||
|
group *gin.RouterGroup,
|
||||||
|
authMiddleware *middleware.AuthMiddleware,
|
||||||
|
appImagesService *service.AppImagesService,
|
||||||
|
) {
|
||||||
|
controller := &AppImagesController{
|
||||||
|
appImagesService: appImagesService,
|
||||||
|
}
|
||||||
|
|
||||||
|
group.GET("/application-images/logo", controller.getLogoHandler)
|
||||||
|
group.GET("/application-images/background", controller.getBackgroundImageHandler)
|
||||||
|
group.GET("/application-images/favicon", controller.getFaviconHandler)
|
||||||
|
|
||||||
|
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
|
||||||
|
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
|
||||||
|
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppImagesController struct {
|
||||||
|
appImagesService *service.AppImagesService
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLogoHandler godoc
|
||||||
|
// @Summary Get logo image
|
||||||
|
// @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)"
|
||||||
|
// @Produce image/png
|
||||||
|
// @Produce image/jpeg
|
||||||
|
// @Produce image/svg+xml
|
||||||
|
// @Success 200 {file} binary "Logo image"
|
||||||
|
// @Router /api/application-images/logo [get]
|
||||||
|
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
|
||||||
|
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
|
||||||
|
imageName := "logoLight"
|
||||||
|
if !lightLogo {
|
||||||
|
imageName = "logoDark"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.getImage(ctx, imageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBackgroundImageHandler godoc
|
||||||
|
// @Summary Get background image
|
||||||
|
// @Description Get the background image for the application
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Produce image/png
|
||||||
|
// @Produce image/jpeg
|
||||||
|
// @Success 200 {file} binary "Background image"
|
||||||
|
// @Router /api/application-images/background [get]
|
||||||
|
func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) {
|
||||||
|
c.getImage(ctx, "background")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFaviconHandler godoc
|
||||||
|
// @Summary Get favicon
|
||||||
|
// @Description Get the favicon for the application
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Produce image/x-icon
|
||||||
|
// @Success 200 {file} binary "Favicon image"
|
||||||
|
// @Router /api/application-images/favicon [get]
|
||||||
|
func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
|
||||||
|
c.getImage(ctx, "favicon")
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateLogoHandler godoc
|
||||||
|
// @Summary Update logo
|
||||||
|
// @Description Update the application logo
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||||
|
// @Param file formData file true "Logo image file"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/application-images/logo [put]
|
||||||
|
func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
|
||||||
|
imageName := "logoLight"
|
||||||
|
if !lightLogo {
|
||||||
|
imageName = "logoDark"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.appImagesService.UpdateImage(file, imageName); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateBackgroundImageHandler godoc
|
||||||
|
// @Summary Update background image
|
||||||
|
// @Description Update the application background image
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param file formData file true "Background image file"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/application-images/background [put]
|
||||||
|
func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.appImagesService.UpdateImage(file, "background"); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateFaviconHandler godoc
|
||||||
|
// @Summary Update favicon
|
||||||
|
// @Description Update the application favicon
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param file formData file true "Favicon file (.ico)"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/application-images/favicon [put]
|
||||||
|
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileType := utils.GetFileExtension(file.Filename)
|
||||||
|
if fileType != "ico" {
|
||||||
|
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.appImagesService.UpdateImage(file, "favicon"); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
|
||||||
|
imagePath, mimeType, err := c.appImagesService.GetImage(name)
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Header("Content-Type", mimeType)
|
||||||
|
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
|
||||||
|
ctx.File(imagePath)
|
||||||
|
}
|
||||||
@@ -44,10 +44,7 @@ type AppConfig struct {
|
|||||||
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
|
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
|
||||||
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
|
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
|
||||||
// Internal
|
// Internal
|
||||||
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
|
||||||
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
|
||||||
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
|
|
||||||
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
|
|
||||||
// Email
|
// Email
|
||||||
SmtpHost AppConfigVariable `key:"smtpHost"`
|
SmtpHost AppConfigVariable `key:"smtpHost"`
|
||||||
SmtpPort AppConfigVariable `key:"smtpPort"`
|
SmtpPort AppConfigVariable `key:"smtpPort"`
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"mime/multipart"
|
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -70,10 +69,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
|||||||
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
|
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
|
||||||
AccentColor: model.AppConfigVariable{Value: "default"},
|
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||||
// Internal
|
// Internal
|
||||||
BackgroundImageType: model.AppConfigVariable{Value: "webp"},
|
InstanceID: model.AppConfigVariable{Value: ""},
|
||||||
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
|
|
||||||
LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
|
|
||||||
InstanceID: model.AppConfigVariable{Value: ""},
|
|
||||||
// Email
|
// Email
|
||||||
SmtpHost: model.AppConfigVariable{},
|
SmtpHost: model.AppConfigVariable{},
|
||||||
SmtpPort: model.AppConfigVariable{},
|
SmtpPort: model.AppConfigVariable{},
|
||||||
@@ -322,39 +318,6 @@ func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable
|
|||||||
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
|
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multipart.FileHeader, imageName string, oldImageType string) (err error) {
|
|
||||||
fileType := strings.ToLower(utils.GetFileExtension(uploadedFile.Filename))
|
|
||||||
mimeType := utils.GetImageMimeType(fileType)
|
|
||||||
if mimeType == "" {
|
|
||||||
return &common.FileTypeNotSupportedError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the updated image
|
|
||||||
imagePath := common.EnvConfig.UploadPath + "/application-images/" + imageName + "." + fileType
|
|
||||||
err = utils.SaveFile(uploadedFile, imagePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the old image if it has a different file type, then update the type in the database
|
|
||||||
if fileType != oldImageType {
|
|
||||||
oldImagePath := common.EnvConfig.UploadPath + "/application-images/" + imageName + "." + oldImageType
|
|
||||||
err = os.Remove(oldImagePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the file type in the database
|
|
||||||
err = s.UpdateAppConfigValues(ctx, imageName+"ImageType", fileType)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
|
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
|
||||||
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
|
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
|
||||||
dest, err := s.loadDbConfigInternal(ctx, s.db)
|
dest, err := s.loadDbConfigInternal(ctx, s.db)
|
||||||
|
|||||||
82
backend/internal/service/app_images_service.go
Normal file
82
backend/internal/service/app_images_service.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"mime/multipart"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AppImagesService struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
extensions map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAppImagesService(extensions map[string]string) *AppImagesService {
|
||||||
|
return &AppImagesService{extensions: extensions}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppImagesService) GetImage(name string) (string, string, error) {
|
||||||
|
ext, err := s.getExtension(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType := utils.GetImageMimeType(ext)
|
||||||
|
if mimeType == "" {
|
||||||
|
return "", "", fmt.Errorf("unsupported image type '%s'", ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", name, ext))
|
||||||
|
return imagePath, mimeType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppImagesService) UpdateImage(file *multipart.FileHeader, imageName string) error {
|
||||||
|
fileType := strings.ToLower(utils.GetFileExtension(file.Filename))
|
||||||
|
mimeType := utils.GetImageMimeType(fileType)
|
||||||
|
if mimeType == "" {
|
||||||
|
return &common.FileTypeNotSupportedError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
currentExt, ok := s.extensions[imageName]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown application image '%s'", imageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", imageName, fileType))
|
||||||
|
|
||||||
|
if err := utils.SaveFile(file, imagePath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentExt != "" && currentExt != fileType {
|
||||||
|
oldImagePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", fmt.Sprintf("%s.%s", imageName, currentExt))
|
||||||
|
if err := os.Remove(oldImagePath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.extensions[imageName] = fileType
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AppImagesService) getExtension(name string) (string, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
ext, ok := s.extensions[name]
|
||||||
|
if !ok || ext == "" {
|
||||||
|
return "", fmt.Errorf("unknown application image '%s'", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.ToLower(ext), nil
|
||||||
|
}
|
||||||
88
backend/internal/service/app_images_service_test.go
Normal file
88
backend/internal/service/app_images_service_test.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/fs"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAppImagesService_GetImage(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
originalUploadPath := common.EnvConfig.UploadPath
|
||||||
|
common.EnvConfig.UploadPath = tempDir
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.EnvConfig.UploadPath = originalUploadPath
|
||||||
|
})
|
||||||
|
|
||||||
|
imagesDir := filepath.Join(tempDir, "application-images")
|
||||||
|
require.NoError(t, os.MkdirAll(imagesDir, 0o755))
|
||||||
|
|
||||||
|
filePath := filepath.Join(imagesDir, "background.webp")
|
||||||
|
require.NoError(t, os.WriteFile(filePath, []byte("data"), fs.FileMode(0o644)))
|
||||||
|
|
||||||
|
service := NewAppImagesService(map[string]string{"background": "webp"})
|
||||||
|
|
||||||
|
path, mimeType, err := service.GetImage("background")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, filePath, path)
|
||||||
|
require.Equal(t, "image/webp", mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppImagesService_UpdateImage(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
originalUploadPath := common.EnvConfig.UploadPath
|
||||||
|
common.EnvConfig.UploadPath = tempDir
|
||||||
|
t.Cleanup(func() {
|
||||||
|
common.EnvConfig.UploadPath = originalUploadPath
|
||||||
|
})
|
||||||
|
|
||||||
|
imagesDir := filepath.Join(tempDir, "application-images")
|
||||||
|
require.NoError(t, os.MkdirAll(imagesDir, 0o755))
|
||||||
|
|
||||||
|
oldPath := filepath.Join(imagesDir, "logoLight.svg")
|
||||||
|
require.NoError(t, os.WriteFile(oldPath, []byte("old"), fs.FileMode(0o644)))
|
||||||
|
|
||||||
|
service := NewAppImagesService(map[string]string{"logoLight": "svg"})
|
||||||
|
|
||||||
|
fileHeader := newFileHeader(t, "logoLight.png", []byte("new"))
|
||||||
|
|
||||||
|
require.NoError(t, service.UpdateImage(fileHeader, "logoLight"))
|
||||||
|
|
||||||
|
_, err := os.Stat(filepath.Join(imagesDir, "logoLight.png"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = os.Stat(oldPath)
|
||||||
|
require.ErrorIs(t, err, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body := &bytes.Buffer{}
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
|
||||||
|
part, err := writer.CreateFormFile("file", filename)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = part.Write(content)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
_, fileHeader, err := req.FormFile("file")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return fileHeader
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
|
|||||||
|
|
||||||
data := &email.TemplateData[V]{
|
data := &email.TemplateData[V]{
|
||||||
AppName: dbConfig.AppName.Value,
|
AppName: dbConfig.AppName.Value,
|
||||||
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
|
LogoURL: common.EnvConfig.AppURL + "/api/application-images/logo",
|
||||||
Data: tData,
|
Data: tData,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -24,6 +25,15 @@ func GetFileExtension(filename string) string {
|
|||||||
return filename
|
return filename
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SplitFileName splits a full file name into name and extension.
|
||||||
|
func SplitFileName(fullName string) (name, ext string) {
|
||||||
|
dot := strings.LastIndex(fullName, ".")
|
||||||
|
if dot == -1 || dot == 0 {
|
||||||
|
return fullName, "" // no extension or hidden file like .gitignore
|
||||||
|
}
|
||||||
|
return fullName[:dot], fullName[dot+1:]
|
||||||
|
}
|
||||||
|
|
||||||
func GetImageMimeType(ext string) string {
|
func GetImageMimeType(ext string) string {
|
||||||
switch ext {
|
switch ext {
|
||||||
case "jpg", "jpeg":
|
case "jpg", "jpeg":
|
||||||
|
|||||||
@@ -2,8 +2,36 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestSplitFileName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
fullName string
|
||||||
|
wantName string
|
||||||
|
wantExt string
|
||||||
|
}{
|
||||||
|
{"background.jpg", "background", "jpg"},
|
||||||
|
{"archive.tar.gz", "archive.tar", "gz"},
|
||||||
|
{".gitignore", ".gitignore", ""},
|
||||||
|
{"noext", "noext", ""},
|
||||||
|
{"a.b.c", "a.b", "c"},
|
||||||
|
{".hidden.ext", ".hidden", "ext"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.fullName, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
name, ext := SplitFileName(tc.fullName)
|
||||||
|
assert.Equal(t, tc.wantName, name)
|
||||||
|
assert.Equal(t, tc.wantExt, ext)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetFileExtension(t *testing.T) {
|
func TestGetFileExtension(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="%lang%">
|
<html lang="%lang%">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="/api/application-configuration/favicon" />
|
<link rel="icon" href="/api/application-images/favicon" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
<link rel="manifest" href="/app.webmanifest" />
|
<link rel="manifest" href="/app.webmanifest" />
|
||||||
|
|||||||
@@ -32,14 +32,14 @@ export default class AppConfigService extends APIService {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', favicon!);
|
formData.append('file', favicon!);
|
||||||
|
|
||||||
await this.api.put(`/application-configuration/favicon`, formData);
|
await this.api.put(`/application-images/favicon`, formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLogo(logo: File, light = true) {
|
async updateLogo(logo: File, light = true) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', logo!);
|
formData.append('file', logo!);
|
||||||
|
|
||||||
await this.api.put(`/application-configuration/logo`, formData, {
|
await this.api.put(`/application-images/logo`, formData, {
|
||||||
params: { light }
|
params: { light }
|
||||||
});
|
});
|
||||||
cachedApplicationLogo.bustCache(light);
|
cachedApplicationLogo.bustCache(light);
|
||||||
@@ -49,7 +49,7 @@ export default class AppConfigService extends APIService {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', backgroundImage!);
|
formData.append('file', backgroundImage!);
|
||||||
|
|
||||||
await this.api.put(`/application-configuration/background-image`, formData);
|
await this.api.put(`/application-images/background`, formData);
|
||||||
cachedBackgroundImage.bustCache();
|
cachedBackgroundImage.bustCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ type CachableImage = {
|
|||||||
|
|
||||||
export const cachedApplicationLogo: CachableImage = {
|
export const cachedApplicationLogo: CachableImage = {
|
||||||
getUrl: (light = true) => {
|
getUrl: (light = true) => {
|
||||||
let url = '/api/application-configuration/logo';
|
let url = '/api/application-images/logo';
|
||||||
if (!light) {
|
if (!light) {
|
||||||
url += '?light=false';
|
url += '?light=false';
|
||||||
}
|
}
|
||||||
return getCachedImageUrl(url);
|
return getCachedImageUrl(url);
|
||||||
},
|
},
|
||||||
bustCache: (light = true) => {
|
bustCache: (light = true) => {
|
||||||
let url = '/api/application-configuration/logo';
|
let url = '/api/application-images/logo';
|
||||||
if (!light) {
|
if (!light) {
|
||||||
url += '?light=false';
|
url += '?light=false';
|
||||||
}
|
}
|
||||||
@@ -25,8 +25,8 @@ export const cachedApplicationLogo: CachableImage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const cachedBackgroundImage: CachableImage = {
|
export const cachedBackgroundImage: CachableImage = {
|
||||||
getUrl: () => getCachedImageUrl('/api/application-configuration/background-image'),
|
getUrl: () => getCachedImageUrl('/api/application-images/background'),
|
||||||
bustCache: () => bustImageCache('/api/application-configuration/background-image')
|
bustCache: () => bustImageCache('/api/application-images/background')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cachedProfilePicture: CachableImage = {
|
export const cachedProfilePicture: CachableImage = {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
imageClass="size-14 p-2"
|
imageClass="size-14 p-2"
|
||||||
label={m.favicon()}
|
label={m.favicon()}
|
||||||
bind:image={favicon}
|
bind:image={favicon}
|
||||||
imageURL="/api/application-configuration/favicon"
|
imageURL="/api/application-images/favicon"
|
||||||
accept="image/x-icon"
|
accept="image/x-icon"
|
||||||
/>
|
/>
|
||||||
<ApplicationImage
|
<ApplicationImage
|
||||||
|
|||||||
@@ -128,15 +128,15 @@ test('Update application images', async ({ page }) => {
|
|||||||
await expect(page.locator('[data-type="success"]')).toHaveText('Images updated successfully');
|
await expect(page.locator('[data-type="success"]')).toHaveText('Images updated successfully');
|
||||||
|
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-configuration/favicon')
|
.get('/api/application-images/favicon')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-configuration/logo?light=true')
|
.get('/api/application-images/logo?light=true')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-configuration/logo?light=false')
|
.get('/api/application-images/logo?light=false')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-configuration/background-image')
|
.get('/api/application-images/background')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user