diff --git a/backend/app/administration.go b/backend/app/administration.go index bde30aa4..f760a1a6 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -103,6 +103,11 @@ const ( // auto-prune options ROUTE_V1_OPTION_AUTO_PRUNE = "/api/v1/option/auto-prune" ROUTE_V1_COMPANY_OPTION_AUTO_PRUNE = "/api/v1/company/:id/option/auto-prune" + // branding + ROUTE_V1_BRANDING = "/api/v1/branding" + ROUTE_V1_BRANDING_IMAGE_SLOT = "/api/v1/branding/image/:slot" + ROUTE_V1_BRANDING_DISPLAY_SLOT = "/api/v1/branding/display/:slot" + ROUTE_V1_BRANDING_SIDE_VISIBLE = "/api/v1/branding/login-side-image/visibility" // installation ROUTE_V1_INSTALL = "/api/v1/install" ROUTE_V1_INSTALL_TEMPLATES = "/api/v1/install/templates" @@ -400,6 +405,14 @@ func setupRoutes( POST(ROUTE_V1_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.SetAutoPrune). GET(ROUTE_V1_COMPANY_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.GetCompanyAutoPrune). POST(ROUTE_V1_COMPANY_OPTION_AUTO_PRUNE, middleware.SessionHandler, controllers.Option.SetCompanyAutoPrune). + // branding, the state and image reads are public so the pre login screen + // can render a custom logo and side image before authentication + GET(ROUTE_V1_BRANDING, controllers.Branding.GetState). + GET(ROUTE_V1_BRANDING_IMAGE_SLOT, controllers.Branding.GetImage). + POST(ROUTE_V1_BRANDING_IMAGE_SLOT, middleware.SessionHandler, controllers.Branding.Upload). + DELETE(ROUTE_V1_BRANDING_IMAGE_SLOT, middleware.SessionHandler, controllers.Branding.Reset). + POST(ROUTE_V1_BRANDING_DISPLAY_SLOT, middleware.SessionHandler, controllers.Branding.SetDisplay). + POST(ROUTE_V1_BRANDING_SIDE_VISIBLE, middleware.SessionHandler, controllers.Branding.SetSideImageVisibility). // domain GET(ROUTE_V1_DOMAIN, middleware.SessionHandler, controllers.Domain.GetAll). GET(ROUTE_V1_DOMAIN_SUBSET, middleware.SessionHandler, controllers.Domain.GetAllOverview). diff --git a/backend/app/controllers.go b/backend/app/controllers.go index 37879c8c..bd0638f0 100644 --- a/backend/app/controllers.go +++ b/backend/app/controllers.go @@ -45,6 +45,7 @@ type Controllers struct { Scim *controller.Scim RemoteBrowser *controller.RemoteBrowserController ReportTemplate *controller.ReportTemplate + Branding *controller.Branding } // NewControllers creates a collection of controllers @@ -235,6 +236,10 @@ func NewControllers( OptionService: services.Option, ExecPath: conf.RemoteBrowser.ExecPath, } + branding := &controller.Branding{ + Common: common, + BrandingService: services.Branding, + } return &Controllers{ Asset: asset, @@ -273,5 +278,6 @@ func NewControllers( Scim: scim, RemoteBrowser: remoteBrowser, ReportTemplate: reportTemplate, + Branding: branding, } } diff --git a/backend/app/services.go b/backend/app/services.go index 75a25e4d..848c376a 100644 --- a/backend/app/services.go +++ b/backend/app/services.go @@ -48,6 +48,7 @@ type Services struct { Scim *service.Scim RemoteBrowser *service.RemoteBrowser ReportTemplate *service.ReportTemplate + Branding *service.Branding } // NewServices creates a collection of services @@ -65,6 +66,7 @@ func NewServices( filePath string, trustedProxies []string, remoteBrowserExecPath string, + brandingPath string, ) *Services { common := service.Common{ Logger: logger, @@ -332,6 +334,13 @@ func NewServices( ReportSendLogRepository: repositories.ReportSendLog, } + brandingService := &service.Branding{ + Common: common, + RootFolder: brandingPath, + OptionRepository: repositories.Option, + FileService: file, + } + return &Services{ CompanyScimConfig: companyScimConfig, CompanyReportConfig: companyReportConfig, @@ -369,5 +378,6 @@ func NewServices( MicrosoftDeviceCode: microsoftDeviceCodeService, RemoteBrowser: remoteBrowser, ReportTemplate: reportTemplate, + Branding: brandingService, } } diff --git a/backend/controller/branding.go b/backend/controller/branding.go new file mode 100644 index 00000000..bac1e167 --- /dev/null +++ b/backend/controller/branding.go @@ -0,0 +1,138 @@ +package controller + +import ( + "io" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/phishingclub/phishingclub/data" + "github.com/phishingclub/phishingclub/service" +) + +// Branding is the controller for install wide UI branding. +type Branding struct { + Common + BrandingService *service.Branding +} + +// GetState returns the branding mode of each slot. It is public so the login +// screen can read it before authentication. +func (c *Branding) GetState(g *gin.Context) { + state, err := c.BrandingService.GetState(g.Request.Context()) + if err != nil { + c.Response.ServerError(g) + return + } + // never cache the state so an admin change is picked up on the next load + g.Header("Cache-Control", "no-store") + c.Response.OK(g, state) +} + +// GetImage streams the uploaded PNG for a slot. It is public so the login +// screen can show a custom logo and side image before authentication. When no +// custom image is stored it returns 404 so the frontend falls back to the +// built in default. +func (c *Branding) GetImage(g *gin.Context) { + slot := g.Param("slot") + content, found, err := c.BrandingService.GetImage(slot) + if err != nil { + c.Response.ServerError(g) + return + } + if !found { + c.Response.NotFound(g) + return + } + g.Header("Cache-Control", "no-cache") + g.Header("X-Content-Type-Options", "nosniff") + g.Data(http.StatusOK, "image/png", content) +} + +// Upload validates and stores an uploaded PNG for a slot. +func (c *Branding) Upload(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + slot := g.Param("slot") + fileHeader, err := g.FormFile("file") + if err != nil { + c.Response.BadRequestMessage(g, "No file selected") + return + } + // reject an oversized upload before reading it into memory + if fileHeader.Size > data.BrandingMaxUploadBytes { + c.Response.BadRequestMessage(g, "File is too large") + return + } + f, err := fileHeader.Open() + if err != nil { + c.Response.BadRequest(g) + return + } + defer f.Close() + // cap the read so an oversized upload can not exhaust memory, one byte over + // the limit so the size validation still rejects it + content, err := io.ReadAll(io.LimitReader(f, data.BrandingMaxUploadBytes+1)) + if err != nil { + c.Response.BadRequest(g) + return + } + err = c.BrandingService.SetImage(g.Request.Context(), session, slot, content) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} + +// Reset removes the uploaded image for a slot, returning it to the default. +func (c *Branding) Reset(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + slot := g.Param("slot") + err := c.BrandingService.Reset(g.Request.Context(), session, slot) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} + +// SetDisplay stores the display fit settings for a slot. +func (c *Branding) SetDisplay(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + slot := g.Param("slot") + var req service.BrandingDisplay + if ok := c.handleParseRequest(g, &req); !ok { + return + } + err := c.BrandingService.SetDisplay(g.Request.Context(), session, slot, req) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} + +// SetSideImageVisibility shows or hides the login side image. Hiding centers +// the login form. +func (c *Branding) SetSideImageVisibility(g *gin.Context) { + session, _, ok := c.handleSession(g) + if !ok { + return + } + var req struct { + Hidden bool `json:"hidden"` + } + if ok := c.handleParseRequest(g, &req); !ok { + return + } + err := c.BrandingService.SetSideImageHidden(g.Request.Context(), session, req.Hidden) + if ok := c.handleErrors(g, err); !ok { + return + } + c.Response.OK(g, gin.H{}) +} diff --git a/backend/data/branding.go b/backend/data/branding.go new file mode 100644 index 00000000..bfdc0cec --- /dev/null +++ b/backend/data/branding.go @@ -0,0 +1,69 @@ +package data + +const ( + // BrandingSlotHeaderLogo is the logo shown in the app header + BrandingSlotHeaderLogo = "header-logo" + // BrandingSlotLoginLogo is the logo shown on the login screen + BrandingSlotLoginLogo = "login-logo" + // BrandingSlotLoginSideImage is the image shown beside the login form + BrandingSlotLoginSideImage = "login-side-image" + + // BrandingModeDefault means the built in asset is used + BrandingModeDefault = "default" + // BrandingModeCustom means an uploaded image is used + BrandingModeCustom = "custom" + + // OptionKeyBrandingLoginSideImageRemoved marks the login side image as hidden + OptionKeyBrandingLoginSideImageRemoved = "branding_login_side_image_removed" + + // OptionKeyBrandingDisplay holds the per slot display settings as a JSON map + OptionKeyBrandingDisplay = "branding_display" + + // BrandingMaxUploadBytes is the largest accepted branding image + BrandingMaxUploadBytes = 5 * 1024 * 1024 + // BrandingMaxImageDimension bounds width and height to stop decompression bombs + BrandingMaxImageDimension = 4096 + + // display fit modes, map to CSS object-fit + BrandingFitContain = "contain" + BrandingFitCover = "cover" + BrandingFitFill = "fill" + + // display background behind the image + BrandingBackgroundNone = "none" + BrandingBackgroundLight = "light" + BrandingBackgroundDark = "dark" + + // display scale is a percentage bound so a value can not break the layout + BrandingScaleMin = 25 + BrandingScaleMax = 200 + BrandingScaleDefault = 100 +) + +// BrandingFits are the accepted fit modes +var BrandingFits = map[string]bool{ + BrandingFitContain: true, + BrandingFitCover: true, + BrandingFitFill: true, +} + +// BrandingBackgrounds are the accepted background values +var BrandingBackgrounds = map[string]bool{ + BrandingBackgroundNone: true, + BrandingBackgroundLight: true, + BrandingBackgroundDark: true, +} + +// BrandingPositionsX are the accepted horizontal positions +var BrandingPositionsX = map[string]bool{"left": true, "center": true, "right": true} + +// BrandingPositionsY are the accepted vertical positions +var BrandingPositionsY = map[string]bool{"top": true, "center": true, "bottom": true} + +// BrandingSlotFilename maps a branding slot to its on disk PNG filename. Only +// slots present here are accepted, so a request can not name an arbitrary path. +var BrandingSlotFilename = map[string]string{ + BrandingSlotHeaderLogo: "header-logo.png", + BrandingSlotLoginLogo: "login-logo.png", + BrandingSlotLoginSideImage: "login-side-image.png", +} diff --git a/backend/main.go b/backend/main.go index 945f4802..6b4a0d84 100644 --- a/backend/main.go +++ b/backend/main.go @@ -131,6 +131,7 @@ func main() { ownManagedTLSPath := fmt.Sprintf("%scerts/own-managed", *flagFilePath) assetPath := fmt.Sprintf("%sassets", *flagFilePath) attachmentsPath := fmt.Sprintf("%sattachments", *flagFilePath) + brandingPath := fmt.Sprintf("%sbranding", *flagFilePath) // print banner and version cli.PrintBanner() @@ -249,6 +250,7 @@ func main() { *flagFilePath, conf.IPSecurity.TrustedProxies, conf.RemoteBrowser.ExecPath, + brandingPath, ) // get entra-id options and setup msal client ssoOpt, err := services.SSO.GetSSOOptionWithoutAuth(context.Background()) diff --git a/backend/service/branding.go b/backend/service/branding.go new file mode 100644 index 00000000..7ace8660 --- /dev/null +++ b/backend/service/branding.go @@ -0,0 +1,411 @@ +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image/png" + "net/http" + "os" + "path/filepath" + + "github.com/go-errors/errors" + "github.com/phishingclub/phishingclub/data" + "github.com/phishingclub/phishingclub/errs" + "github.com/phishingclub/phishingclub/model" + "github.com/phishingclub/phishingclub/repository" + "github.com/phishingclub/phishingclub/vo" + "gorm.io/gorm" +) + +// Branding is a service for install wide UI branding. It stores uploaded PNG +// images on disk and tracks whether the login side image has been hidden. +type Branding struct { + Common + RootFolder string + OptionRepository *repository.Option + FileService *File +} + +// BrandingDisplay is how an image is fitted within its area on screen. +type BrandingDisplay struct { + Fit string `json:"fit"` + Scale int `json:"scale"` + Background string `json:"background"` + PositionX string `json:"positionX"` + PositionY string `json:"positionY"` +} + +// BrandingState is the branding state returned to the frontend. Each slot mode +// is 'default' or 'custom'. The login side image can additionally be hidden, +// which is independent of whether a custom image is stored. Display holds the +// per slot fit settings keyed by slot. +type BrandingState struct { + HeaderLogo string `json:"headerLogo"` + LoginLogo string `json:"loginLogo"` + LoginSideImage string `json:"loginSideImage"` + LoginSideImageHidden bool `json:"loginSideImageHidden"` + Display map[string]BrandingDisplay `json:"display"` +} + +// GetState returns the branding state. There is no authorization check as the +// login screen reads this before a user is authenticated. +func (b *Branding) GetState(ctx context.Context) (*BrandingState, error) { + hidden, err := b.isSideImageRemoved(ctx) + if err != nil { + return nil, errs.Wrap(err) + } + display, err := b.getDisplayMap(ctx) + if err != nil { + return nil, errs.Wrap(err) + } + return &BrandingState{ + HeaderLogo: b.slotMode(data.BrandingSlotHeaderLogo), + LoginLogo: b.slotMode(data.BrandingSlotLoginLogo), + LoginSideImage: b.slotMode(data.BrandingSlotLoginSideImage), + LoginSideImageHidden: hidden, + Display: display, + }, nil +} + +// defaultDisplay is the fit settings for a slot when none are stored. Logos +// show whole (contain); the side image fills its area (cover). +func defaultDisplay(slot string) BrandingDisplay { + fit := data.BrandingFitContain + if slot == data.BrandingSlotLoginSideImage { + fit = data.BrandingFitCover + } + // the login logo sits top left like the original; other slots center + posX, posY := "center", "center" + if slot == data.BrandingSlotLoginLogo { + posX, posY = "left", "top" + } + return BrandingDisplay{ + Fit: fit, + Scale: data.BrandingScaleDefault, + Background: data.BrandingBackgroundNone, + PositionX: posX, + PositionY: posY, + } +} + +// getDisplayMap returns the stored display settings for every slot, filling in +// defaults for any slot or field that is unset. +func (b *Branding) getDisplayMap(ctx context.Context) (map[string]BrandingDisplay, error) { + stored := map[string]BrandingDisplay{} + opt, err := b.OptionRepository.GetByKey(ctx, data.OptionKeyBrandingDisplay) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errs.Wrap(err) + } + if err == nil && opt.Value.String() != "" { + if uErr := json.Unmarshal([]byte(opt.Value.String()), &stored); uErr != nil { + b.Logger.Errorw("failed to parse branding display settings", "error", uErr) + stored = map[string]BrandingDisplay{} + } + } + out := map[string]BrandingDisplay{} + for slot := range data.BrandingSlotFilename { + out[slot] = withDisplayDefaults(slot, stored[slot]) + } + return out, nil +} + +// withDisplayDefaults fills any empty or out of range field with its default. +func withDisplayDefaults(slot string, d BrandingDisplay) BrandingDisplay { + def := defaultDisplay(slot) + if !data.BrandingFits[d.Fit] { + d.Fit = def.Fit + } + if !data.BrandingBackgrounds[d.Background] { + d.Background = def.Background + } + if !data.BrandingPositionsX[d.PositionX] { + d.PositionX = def.PositionX + } + if !data.BrandingPositionsY[d.PositionY] { + d.PositionY = def.PositionY + } + if d.Scale < data.BrandingScaleMin || d.Scale > data.BrandingScaleMax { + d.Scale = def.Scale + } + return d +} + +// SetDisplay stores the display settings for a slot. +func (b *Branding) SetDisplay( + ctx context.Context, + session *model.Session, + slot string, + display BrandingDisplay, +) error { + ae := NewAuditEvent("Branding.SetDisplay", session) + ae.Details["slot"] = slot + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + b.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + b.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + if _, ok := data.BrandingSlotFilename[slot]; !ok { + return errs.NewValidationError(fmt.Errorf("unknown branding slot")) + } + // clamp and default any invalid field so a bad value can not break rendering + display = withDisplayDefaults(slot, display) + + stored := map[string]BrandingDisplay{} + opt, getErr := b.OptionRepository.GetByKey(ctx, data.OptionKeyBrandingDisplay) + if getErr != nil && !errors.Is(getErr, gorm.ErrRecordNotFound) { + return errs.Wrap(getErr) + } + if getErr == nil && opt.Value.String() != "" { + if uErr := json.Unmarshal([]byte(opt.Value.String()), &stored); uErr != nil { + // corrupt value, start fresh rather than fail; log so it is visible + b.Logger.Errorw("failed to parse branding display settings", "error", uErr) + stored = map[string]BrandingDisplay{} + } + } + stored[slot] = display + blob, err := json.Marshal(stored) + if err != nil { + return errs.Wrap(err) + } + if err := b.upsertOption(ctx, data.OptionKeyBrandingDisplay, string(blob)); err != nil { + return errs.Wrap(err) + } + b.AuditLogAuthorized(ae) + return nil +} + +// GetImage returns the uploaded PNG bytes for a slot. The found flag is false +// when no custom image is stored, so the caller falls back to the built in +// default. There is no authorization check as branding images are shown on the +// pre login screen. +func (b *Branding) GetImage(slot string) ([]byte, bool, error) { + filename, ok := data.BrandingSlotFilename[slot] + if !ok { + return nil, false, nil + } + content, err := os.ReadFile(filepath.Join(b.RootFolder, filename)) + if err != nil { + if os.IsNotExist(err) { + return nil, false, nil + } + b.Logger.Errorw("failed to read branding image", "slot", slot, "error", err) + return nil, false, errs.Wrap(err) + } + return content, true, nil +} + +// SetImage validates and stores an uploaded PNG for a slot. +func (b *Branding) SetImage( + ctx context.Context, + session *model.Session, + slot string, + content []byte, +) error { + ae := NewAuditEvent("Branding.SetImage", session) + ae.Details["slot"] = slot + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + b.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + b.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + filename, ok := data.BrandingSlotFilename[slot] + if !ok { + return errs.NewValidationError(fmt.Errorf("unknown branding slot")) + } + if err := validatePNG(content); err != nil { + return err + } + // write through an os.Root sandbox using the shared file service, the same + // safe write path the asset and attachment uploads use + if err := os.MkdirAll(b.RootFolder, 0755); err != nil { + b.Logger.Errorw("failed to create branding folder", "error", err) + return errs.Wrap(err) + } + root, err := os.OpenRoot(b.RootFolder) + if err != nil { + b.Logger.Errorw("failed to open branding folder", "error", err) + return errs.Wrap(err) + } + defer root.Close() + if err := b.FileService.UploadFile(root, filename, bytes.NewBuffer(content), true); err != nil { + b.Logger.Errorw("failed to write branding image", "slot", slot, "error", err) + return errs.Wrap(err) + } + // uploading a side image clears any previous hidden state + if slot == data.BrandingSlotLoginSideImage { + if err := b.setSideImageRemoved(ctx, false); err != nil { + return errs.Wrap(err) + } + } + b.AuditLogAuthorized(ae) + return nil +} + +// Reset removes any uploaded image for a slot, returning it to the built in +// default. For the login side image it also clears the hidden state. +func (b *Branding) Reset( + ctx context.Context, + session *model.Session, + slot string, +) error { + ae := NewAuditEvent("Branding.Reset", session) + ae.Details["slot"] = slot + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + b.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + b.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + filename, ok := data.BrandingSlotFilename[slot] + if !ok { + return errs.NewValidationError(fmt.Errorf("unknown branding slot")) + } + if err := os.Remove(filepath.Join(b.RootFolder, filename)); err != nil && !os.IsNotExist(err) { + b.Logger.Errorw("failed to remove branding image", "slot", slot, "error", err) + return errs.Wrap(err) + } + if slot == data.BrandingSlotLoginSideImage { + if err := b.setSideImageRemoved(ctx, false); err != nil { + return errs.Wrap(err) + } + } + b.AuditLogAuthorized(ae) + return nil +} + +// SetSideImageHidden shows or hides the login side image. Hiding centers the +// login form. Any uploaded side image is kept so showing it again restores the +// custom image; use Reset to remove the uploaded image entirely. +func (b *Branding) SetSideImageHidden( + ctx context.Context, + session *model.Session, + hidden bool, +) error { + ae := NewAuditEvent("Branding.SetSideImageHidden", session) + ae.Details["hidden"] = hidden + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + b.LogAuthError(err) + return errs.Wrap(err) + } + if !isAuthorized { + b.AuditLogNotAuthorized(ae) + return errs.ErrAuthorizationFailed + } + if err := b.setSideImageRemoved(ctx, hidden); err != nil { + return errs.Wrap(err) + } + b.AuditLogAuthorized(ae) + return nil +} + +// slotMode resolves whether a slot has a custom uploaded image. +func (b *Branding) slotMode(slot string) string { + if b.hasCustomImage(slot) { + return data.BrandingModeCustom + } + return data.BrandingModeDefault +} + +// hasCustomImage reports whether an uploaded image exists for the slot. +func (b *Branding) hasCustomImage(slot string) bool { + filename, ok := data.BrandingSlotFilename[slot] + if !ok { + return false + } + _, err := os.Stat(filepath.Join(b.RootFolder, filename)) + return err == nil +} + +// isSideImageRemoved reads the hidden flag for the login side image. +func (b *Branding) isSideImageRemoved(ctx context.Context) (bool, error) { + opt, err := b.OptionRepository.GetByKey(ctx, data.OptionKeyBrandingLoginSideImageRemoved) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, errs.Wrap(err) + } + return opt.Value.String() == "true", nil +} + +// setSideImageRemoved upserts the hidden flag for the login side image. +func (b *Branding) setSideImageRemoved(ctx context.Context, removed bool) error { + value := "false" + if removed { + value = "true" + } + return b.upsertOption(ctx, data.OptionKeyBrandingLoginSideImageRemoved, value) +} + +// upsertOption inserts or updates a single option row by key. +func (b *Branding) upsertOption(ctx context.Context, key string, value string) error { + valueVO, err := vo.NewOptionalString1MB(value) + if err != nil { + return errs.NewValidationError(err) + } + opt := &model.Option{ + Key: *vo.NewString127Must(key), + Value: *valueVO, + } + _, getErr := b.OptionRepository.GetByKey(ctx, key) + if getErr != nil { + if !errors.Is(getErr, gorm.ErrRecordNotFound) { + return errs.Wrap(getErr) + } + if _, insertErr := b.OptionRepository.Insert(ctx, opt); insertErr != nil { + return errs.Wrap(insertErr) + } + return nil + } + if updateErr := b.OptionRepository.UpdateByKey(ctx, opt); updateErr != nil { + return errs.Wrap(updateErr) + } + return nil +} + +// validatePNG rejects anything that is not a small, sane PNG. This is the only +// upload type gate in the branding path, so it is strict: it checks the sniffed +// content type, decodes the image to prove it is a real PNG and not a polyglot, +// and bounds the dimensions to stop decompression bombs. +func validatePNG(content []byte) error { + if len(content) == 0 { + return errs.NewValidationError(fmt.Errorf("file is empty")) + } + if len(content) > data.BrandingMaxUploadBytes { + return errs.NewValidationError(fmt.Errorf("file is too large")) + } + sniffLen := len(content) + if sniffLen > 512 { + sniffLen = 512 + } + if http.DetectContentType(content[:sniffLen]) != "image/png" { + return errs.NewValidationError(fmt.Errorf("file is not a png")) + } + cfg, err := png.DecodeConfig(bytes.NewReader(content)) + if err != nil { + return errs.NewValidationError(fmt.Errorf("file is not a valid png")) + } + if cfg.Width <= 0 || cfg.Height <= 0 || + cfg.Width > data.BrandingMaxImageDimension || + cfg.Height > data.BrandingMaxImageDimension { + return errs.NewValidationError(fmt.Errorf("image dimensions are out of bounds")) + } + if _, err := png.Decode(bytes.NewReader(content)); err != nil { + return errs.NewValidationError(fmt.Errorf("file is not a valid png")) + } + return nil +} diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 208eec71..d1b726f0 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -3418,6 +3418,65 @@ export class API { } }; + /** + * branding is the API for install wide UI branding. The state and image + * reads are public so the login screen can render a custom logo and side + * image before the user is authenticated. + */ + branding = { + /** + * @returns {Promise} + */ + getState: async () => { + return await getJSON(this.getPath(`/branding`)); + }, + /** + * imageURL returns the public URL for a branding slot image. An optional + * version keeps the header and login screen in sync right after an upload + * by busting the browser cache. + * @param {string} slot + * @param {number|string} [version] + * @returns {string} + */ + imageURL: (slot, version) => { + const path = this.getPath(`/branding/image/${slot}`); + return version ? `${path}?v=${version}` : path; + }, + /** + * @param {string} slot + * @param {File} file + * @returns {Promise} + */ + upload: async (slot, file) => { + const formData = new FormData(); + formData.append('file', file); + return await postMultipart(this.getPath(`/branding/image/${slot}`), formData); + }, + /** + * @param {string} slot + * @returns {Promise} + */ + reset: async (slot) => { + return await deleteReq(this.getPath(`/branding/image/${slot}`)); + }, + /** + * @param {boolean} hidden + * @returns {Promise} + */ + setSideImageHidden: async (hidden) => { + return await postJSON(this.getPath(`/branding/login-side-image/visibility`), { hidden }); + }, + /** + * setDisplay stores how an image is fitted within its area. + * @param {string} slot + * @param {{fit:string, scale:number, background:string, positionX:string, positionY:string}} display + * @returns {Promise} + */ + setDisplay: async (slot, display) => { + return await postJSON(this.getPath(`/branding/display/${slot}`), display); + } + }; + /** * proxy is the API for Proxy related operations. */ diff --git a/frontend/src/lib/components/BrandingAdjust.svelte b/frontend/src/lib/components/BrandingAdjust.svelte new file mode 100644 index 00000000..e3d05d8c --- /dev/null +++ b/frontend/src/lib/components/BrandingAdjust.svelte @@ -0,0 +1,122 @@ + + +
+
+ Fit + Background + Horizontal + Vertical +
+ + + +
+ +
+
diff --git a/frontend/src/lib/components/BrandingImage.svelte b/frontend/src/lib/components/BrandingImage.svelte new file mode 100644 index 00000000..cf5ae4ae --- /dev/null +++ b/frontend/src/lib/components/BrandingImage.svelte @@ -0,0 +1,16 @@ + + +
+ +
diff --git a/frontend/src/lib/components/FileField.svelte b/frontend/src/lib/components/FileField.svelte index e0851324..c2b37a47 100644 --- a/frontend/src/lib/components/FileField.svelte +++ b/frontend/src/lib/components/FileField.svelte @@ -42,23 +42,27 @@