mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-09 16:53:59 +02:00
added branding settings
Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
18 files changed
+1372
-30
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<ApiResponse>}
|
||||
*/
|
||||
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<ApiResponse>}
|
||||
*/
|
||||
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<ApiResponse>}
|
||||
*/
|
||||
reset: async (slot) => {
|
||||
return await deleteReq(this.getPath(`/branding/image/${slot}`));
|
||||
},
|
||||
/**
|
||||
* @param {boolean} hidden
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
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<ApiResponse>}
|
||||
*/
|
||||
setDisplay: async (slot, display) => {
|
||||
return await postJSON(this.getPath(`/branding/display/${slot}`), display);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* proxy is the API for Proxy related operations.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<script>
|
||||
// Compact adjustment controls for a branding image: fit, size, background and
|
||||
// position. Uses the app's TextFieldSelect so the dropdowns match the rest of
|
||||
// the settings UI. Emits 'input' live while dragging the size slider so the
|
||||
// preview updates, and 'change' when a value is committed so it can be saved.
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import TextFieldSelect from '$lib/components/TextFieldSelect.svelte';
|
||||
|
||||
export let display = {};
|
||||
export let disabled = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
// unique id prefix so several instances on the page do not clash
|
||||
const uid = Math.random().toString(36).slice(2, 8);
|
||||
|
||||
const fitOptions = [
|
||||
{ value: 'contain', label: 'Contain' },
|
||||
{ value: 'cover', label: 'Cover' },
|
||||
{ value: 'fill', label: 'Stretch' }
|
||||
];
|
||||
const backgroundOptions = [
|
||||
{ value: 'none', label: 'Transparent' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' }
|
||||
];
|
||||
const positionXOptions = [
|
||||
{ value: 'left', label: 'Left' },
|
||||
{ value: 'center', label: 'Center' },
|
||||
{ value: 'right', label: 'Right' }
|
||||
];
|
||||
const positionYOptions = [
|
||||
{ value: 'top', label: 'Top' },
|
||||
{ value: 'center', label: 'Center' },
|
||||
{ value: 'bottom', label: 'Bottom' }
|
||||
];
|
||||
|
||||
let fit = 'contain';
|
||||
let scale = 100;
|
||||
let background = 'none';
|
||||
let positionX = 'center';
|
||||
let positionY = 'center';
|
||||
|
||||
// keep the controls in sync with the incoming settings
|
||||
$: sync(display);
|
||||
function sync(d) {
|
||||
fit = d.fit || 'contain';
|
||||
scale = d.scale ?? 100;
|
||||
background = d.background || 'none';
|
||||
positionX = d.positionX || 'center';
|
||||
positionY = d.positionY || 'center';
|
||||
}
|
||||
|
||||
const current = () => ({ fit, scale: Number(scale), background, positionX, positionY });
|
||||
const live = () => dispatch('input', current());
|
||||
const commit = () => dispatch('change', current());
|
||||
const selectChanged = () => {
|
||||
live();
|
||||
commit();
|
||||
};
|
||||
|
||||
const labelClass =
|
||||
'text-xs font-semibold text-slate-500 dark:text-gray-400 mb-1 transition-colors duration-200';
|
||||
</script>
|
||||
|
||||
<div class="mt-3 space-y-1">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<TextFieldSelect
|
||||
id="{uid}-fit"
|
||||
size="small"
|
||||
bind:value={fit}
|
||||
options={fitOptions}
|
||||
onSelect={selectChanged}>Fit</TextFieldSelect
|
||||
>
|
||||
<TextFieldSelect
|
||||
id="{uid}-bg"
|
||||
size="small"
|
||||
bind:value={background}
|
||||
options={backgroundOptions}
|
||||
onSelect={selectChanged}>Background</TextFieldSelect
|
||||
>
|
||||
<TextFieldSelect
|
||||
id="{uid}-px"
|
||||
size="small"
|
||||
bind:value={positionX}
|
||||
options={positionXOptions}
|
||||
onSelect={selectChanged}>Horizontal</TextFieldSelect
|
||||
>
|
||||
<TextFieldSelect
|
||||
id="{uid}-py"
|
||||
size="small"
|
||||
bind:value={positionY}
|
||||
options={positionYOptions}
|
||||
onSelect={selectChanged}>Vertical</TextFieldSelect
|
||||
>
|
||||
</div>
|
||||
|
||||
<label class="flex flex-col pt-1">
|
||||
<span class={labelClass}>Size {scale}%</span>
|
||||
<input
|
||||
type="range"
|
||||
min="25"
|
||||
max="200"
|
||||
step="5"
|
||||
bind:value={scale}
|
||||
on:input={live}
|
||||
on:change={commit}
|
||||
{disabled}
|
||||
class="w-full accent-cta-blue dark:accent-highlight-blue"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex justify-end pt-1">
|
||||
<button
|
||||
type="button"
|
||||
{disabled}
|
||||
on:click={() => dispatch('reset')}
|
||||
class="text-xs font-semibold text-cta-blue dark:text-highlight-blue hover:underline disabled:opacity-50 transition-colors duration-200"
|
||||
>
|
||||
Reset adjustments
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script>
|
||||
// Renders a branding image inside a fixed box, applying the persisted display
|
||||
// settings (fit, scale, background, position). The box is always bounded so an
|
||||
// oversized upload can never break the layout.
|
||||
import { brandingImageStyle, brandingBoxStyle } from '$lib/store/branding';
|
||||
|
||||
export let src;
|
||||
export let alt = '';
|
||||
export let display = {};
|
||||
// sizing and rounding classes for the box, e.g. 'h-12 w-40 rounded-md'
|
||||
export let boxClass = '';
|
||||
</script>
|
||||
|
||||
<div class="flex {boxClass}" style={brandingBoxStyle(display)}>
|
||||
<img {src} {alt} draggable="false" style={brandingImageStyle(display)} />
|
||||
</div>
|
||||
@@ -42,23 +42,27 @@
|
||||
</script>
|
||||
|
||||
<label class="flex flex-col py-2 w-56">
|
||||
<div class="flex items-center">
|
||||
<p class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200">
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200 h-6 flex items-center"
|
||||
{#if $$slots.default || toolTipText.length > 0 || optional === true}
|
||||
<div class="flex items-center">
|
||||
<p
|
||||
class="font-semibold text-slate-600 dark:text-gray-300 py-2 transition-colors duration-200"
|
||||
>
|
||||
<p class="text-slate-600 dark:text-gray-300 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<slot />
|
||||
</p>
|
||||
{#if toolTipText.length > 0}
|
||||
<ToolTip>
|
||||
{toolTipText}
|
||||
</ToolTip>
|
||||
{/if}
|
||||
{#if optional === true}
|
||||
<div
|
||||
class="bg-gray-100 dark:bg-gray-700 ml-2 px-2 rounded-md transition-colors duration-200 h-6 flex items-center"
|
||||
>
|
||||
<p class="text-slate-600 dark:text-gray-300 text-xs">optional</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<input
|
||||
id="files"
|
||||
type="file"
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { branding, loadBranding, headerLogoSrc, brandingDisplayFor, BRANDING_SLOT } from '$lib/store/branding';
|
||||
import BrandingImage from '$lib/components/BrandingImage.svelte';
|
||||
|
||||
onMount(() => loadBranding());
|
||||
$: logoSrc = headerLogoSrc($branding);
|
||||
$: display = brandingDisplayFor($branding, BRANDING_SLOT.headerLogo);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-gray-300 dark:bg-gray-700 w-full h-1/10 flex justify-center transition-colors duration-200"
|
||||
>
|
||||
<img alt="logo" src="/logo-white.svg" class="w-1/3 sm:w-1/4 lg:w-2/10 xl:w-1/10 2xl:w-1/10" />
|
||||
<BrandingImage src={logoSrc} alt="logo" {display} boxClass="h-16 w-64 max-w-full" />
|
||||
</div>
|
||||
@@ -1,5 +1,12 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { branding, loadBranding, headerLogoSrc, brandingDisplayFor, BRANDING_SLOT } from '$lib/store/branding';
|
||||
import BrandingImage from '$lib/components/BrandingImage.svelte';
|
||||
|
||||
onMount(() => loadBranding());
|
||||
$: logoSrc = headerLogoSrc($branding);
|
||||
$: display = brandingDisplayFor($branding, BRANDING_SLOT.headerLogo);
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -9,7 +16,7 @@
|
||||
role="button"
|
||||
class="flex items-center w-40 sm:w-40 md:w-42 lg:w-56 justify-center py-4 my-6 ml-4 cursor-pointer"
|
||||
>
|
||||
<img draggable="false" src="/logo-white.svg" alt="logo" />
|
||||
<BrandingImage src={logoSrc} alt="logo" {display} boxClass="h-12 w-full rounded" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { menu, mobileTopMenu } from '$lib/consts/navigation';
|
||||
import { shouldHideMenuItem } from '$lib/utils/common';
|
||||
import ConditionalDisplay from '../ConditionalDisplay.svelte';
|
||||
import ThemeToggle from '../ThemeToggle.svelte';
|
||||
import { branding, loadBranding, headerLogoSrc, brandingDisplayFor, BRANDING_SLOT } from '$lib/store/branding';
|
||||
import BrandingImage from '$lib/components/BrandingImage.svelte';
|
||||
|
||||
onMount(() => loadBranding());
|
||||
$: logoSrc = headerLogoSrc($branding);
|
||||
$: logoDisplay = brandingDisplayFor($branding, BRANDING_SLOT.headerLogo);
|
||||
|
||||
export let visible = false;
|
||||
export let onClickLogout;
|
||||
@@ -177,7 +184,7 @@
|
||||
<div
|
||||
class="mobile-menu-header flex justify-between h-20 items-center bg-pc-darkblue/90 dark:bg-gray-900/90 px-6 border-b border-white/10 dark:border-gray-700/50"
|
||||
>
|
||||
<img class="w-40 h-auto" src="/logo-white.svg" alt="logo" />
|
||||
<BrandingImage src={logoSrc} alt="logo" display={logoDisplay} boxClass="h-12 w-40" />
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex items-center justify-center w-12 h-12 rounded-lg hover:bg-white/10 dark:hover:bg-gray-600/30 transition-all duration-200"
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { api } from '$lib/api/apiProxy';
|
||||
import { API } from '$lib/api/api.js';
|
||||
|
||||
// slot names, must match the backend data.BrandingSlot* constants
|
||||
export const BRANDING_SLOT = {
|
||||
headerLogo: 'header-logo',
|
||||
loginLogo: 'login-logo',
|
||||
loginSideImage: 'login-side-image'
|
||||
};
|
||||
|
||||
// default assets bundled with the app, used when no custom image is uploaded
|
||||
export const BRANDING_DEFAULTS = {
|
||||
headerLogo: '/logo-white.svg',
|
||||
loginLogoLight: '/logo-blue.svg',
|
||||
loginLogoDark: '/logo-white.svg',
|
||||
loginSideImage: '/login-graphics.svg'
|
||||
};
|
||||
|
||||
// default display settings per slot, mirrors the backend defaults so the UI
|
||||
// renders correctly before the state loads
|
||||
const defaultDisplay = (slot) => ({
|
||||
fit: slot === BRANDING_SLOT.loginSideImage ? 'cover' : 'contain',
|
||||
scale: 100,
|
||||
background: 'none',
|
||||
// the login logo sits top left like the original; other slots center
|
||||
positionX: slot === BRANDING_SLOT.loginLogo ? 'left' : 'center',
|
||||
positionY: slot === BRANDING_SLOT.loginLogo ? 'top' : 'center'
|
||||
});
|
||||
|
||||
export const BRANDING_DEFAULT_DISPLAY = {
|
||||
[BRANDING_SLOT.headerLogo]: defaultDisplay(BRANDING_SLOT.headerLogo),
|
||||
[BRANDING_SLOT.loginLogo]: defaultDisplay(BRANDING_SLOT.loginLogo),
|
||||
[BRANDING_SLOT.loginSideImage]: defaultDisplay(BRANDING_SLOT.loginSideImage)
|
||||
};
|
||||
|
||||
// branding holds the current branding state. Each slot mode is 'default' or
|
||||
// 'custom'; the login side image can also be hidden.
|
||||
export const branding = writable({
|
||||
loaded: false,
|
||||
headerLogo: 'default',
|
||||
loginLogo: 'default',
|
||||
loginSideImage: 'default',
|
||||
loginSideImageHidden: false,
|
||||
display: { ...BRANDING_DEFAULT_DISPLAY },
|
||||
// version busts the image cache after an upload or reset
|
||||
version: 0
|
||||
});
|
||||
|
||||
// backgroundColor maps the background setting to a CSS color
|
||||
const BRANDING_BG = { none: 'transparent', light: '#ffffff', dark: '#111827' };
|
||||
|
||||
// brandingImageStyle builds the inline style for an image element from its
|
||||
// display settings. The image fills its box; object-fit and object-position
|
||||
// place it, transform scales it, and the box supplies the background.
|
||||
export const brandingImageStyle = (display) => {
|
||||
const d = display || {};
|
||||
const fit = d.fit || 'contain';
|
||||
const scale = (d.scale ?? 100) / 100;
|
||||
const posX = d.positionX || 'center';
|
||||
const posY = d.positionY || 'center';
|
||||
return (
|
||||
`width:100%;height:100%;object-fit:${fit};` +
|
||||
`object-position:${posX} ${posY};` +
|
||||
`transform:scale(${scale});transform-origin:${posX} ${posY};`
|
||||
);
|
||||
};
|
||||
|
||||
// brandingBoxStyle builds the inline style for the box wrapping the image
|
||||
export const brandingBoxStyle = (display) => {
|
||||
const bg = BRANDING_BG[(display && display.background) || 'none'] || 'transparent';
|
||||
return `background:${bg};overflow:hidden;`;
|
||||
};
|
||||
|
||||
// brandingDisplayFor returns the display settings for a slot with defaults
|
||||
export const brandingDisplayFor = (state, slot) =>
|
||||
(state.display && state.display[slot]) || defaultDisplay(slot);
|
||||
|
||||
// guards against firing more than one initial fetch when several components
|
||||
// mount at once
|
||||
let inFlight = null;
|
||||
|
||||
// loadBranding fetches the branding state. Safe to call on the login screen
|
||||
// before authentication as the endpoint is public. Concurrent calls share one
|
||||
// in flight request; the state endpoint is never cached so each fetch is fresh.
|
||||
export const loadBranding = async () => {
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const res = await api.branding.getState();
|
||||
if (res.success && res.data) {
|
||||
branding.set({
|
||||
loaded: true,
|
||||
headerLogo: res.data.headerLogo,
|
||||
loginLogo: res.data.loginLogo,
|
||||
loginSideImage: res.data.loginSideImage,
|
||||
loginSideImageHidden: !!res.data.loginSideImageHidden,
|
||||
display: { ...BRANDING_DEFAULT_DISPLAY, ...(res.data.display || {}) },
|
||||
version: Date.now()
|
||||
});
|
||||
} else {
|
||||
branding.update((b) => ({ ...b, loaded: true }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('failed to load branding', e);
|
||||
branding.update((b) => ({ ...b, loaded: true }));
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
return inFlight;
|
||||
};
|
||||
|
||||
// brandingImageURL builds the public image URL for a slot. It must use the raw
|
||||
// API instance, not the apiProxy: the proxy wraps every method as an async
|
||||
// response handler, so calling this synchronous URL builder through it would
|
||||
// return a Promise and the image would fail to load.
|
||||
export const brandingImageURL = (slot, version) =>
|
||||
API.instance.branding.imageURL(slot, version);
|
||||
|
||||
// headerLogoSrc resolves the header logo URL for the current state.
|
||||
export const headerLogoSrc = (state) =>
|
||||
state.headerLogo === 'custom'
|
||||
? brandingImageURL(BRANDING_SLOT.headerLogo, state.version)
|
||||
: BRANDING_DEFAULTS.headerLogo;
|
||||
@@ -18,6 +18,31 @@
|
||||
import { page } from '$app/stores';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import { setupTheme, setupOSThemeListener, theme } from '$lib/theme.js';
|
||||
import {
|
||||
branding,
|
||||
loadBranding,
|
||||
brandingImageURL,
|
||||
brandingDisplayFor,
|
||||
BRANDING_SLOT,
|
||||
BRANDING_DEFAULTS
|
||||
} from '$lib/store/branding';
|
||||
import BrandingImage from '$lib/components/BrandingImage.svelte';
|
||||
|
||||
// branding resolves the login logo and side image, falling back to the
|
||||
// bundled defaults when no custom image is uploaded
|
||||
$: loginLogoSrc =
|
||||
$branding.loginLogo === 'custom'
|
||||
? brandingImageURL(BRANDING_SLOT.loginLogo, $branding.version)
|
||||
: $theme === 'dark'
|
||||
? BRANDING_DEFAULTS.loginLogoDark
|
||||
: BRANDING_DEFAULTS.loginLogoLight;
|
||||
$: loginLogoDisplay = brandingDisplayFor($branding, BRANDING_SLOT.loginLogo);
|
||||
$: sideImageRemoved = $branding.loginSideImageHidden;
|
||||
$: sideImageSrc =
|
||||
$branding.loginSideImage === 'custom'
|
||||
? brandingImageURL(BRANDING_SLOT.loginSideImage, $branding.version)
|
||||
: BRANDING_DEFAULTS.loginSideImage;
|
||||
$: sideImageDisplay = brandingDisplayFor($branding, BRANDING_SLOT.loginSideImage);
|
||||
|
||||
// services
|
||||
const appState = AppStateService.instance;
|
||||
@@ -50,6 +75,8 @@
|
||||
// initialize theme system
|
||||
setupTheme();
|
||||
setupOSThemeListener();
|
||||
// load branding so a custom logo and side image show before login
|
||||
loadBranding();
|
||||
|
||||
// if the user is already logged in, we want to redirect to the dashboard
|
||||
if (appState.isLoggedIn()) {
|
||||
@@ -234,7 +261,9 @@
|
||||
|
||||
<HeadTitle title="Sign in" />
|
||||
<main
|
||||
class="h-screen grid-cols-1 grid md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 bg-white dark:bg-gray-900 transition-colors duration-200"
|
||||
class="h-screen grid grid-cols-1 {sideImageRemoved
|
||||
? ''
|
||||
: 'lg:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2'} bg-white dark:bg-gray-900 transition-colors duration-200"
|
||||
>
|
||||
<!-- theme toggle -->
|
||||
<div class="fixed top-3 right-6 z-50">
|
||||
@@ -242,13 +271,16 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<img
|
||||
class="fixed center top-6 w-1/4 md:w-1/4 lg:w-1/6 xl:w-1/6 2xl:w-1/6 lg:top-6 lg:left-4 xl:top-6 xl:left-4 2xl:top-6 2xl:left-4"
|
||||
src={$theme === 'dark' ? '/logo-white.svg' : '/logo-blue.svg'}
|
||||
<BrandingImage
|
||||
src={loginLogoSrc}
|
||||
alt="phishing club logo"
|
||||
display={loginLogoDisplay}
|
||||
boxClass="fixed top-6 left-4 z-40 w-1/4 md:w-1/4 lg:w-1/6 h-24"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-4 w-full sm:w-full md:w-3/4 lg:w-2/3 xl:w-2/3 2xl:w-2/3"
|
||||
class="flex flex-col items-center justify-center p-4 w-full sm:w-full md:w-3/4 lg:w-2/3 xl:w-2/3 2xl:w-2/3 {sideImageRemoved
|
||||
? 'lg:max-w-xl'
|
||||
: ''}"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center w-full p-4">
|
||||
<h1
|
||||
@@ -439,10 +471,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="overflow-hidden hidden sm:hidden md:hidden lg:flex xl:flex 2xl:flex min-w-0 w-full h-full bg-right bg-cover"
|
||||
style="background-image: url('/login-graphics.svg'); background-repeat: no-repeat; background-position: right center; background-size: cover;"
|
||||
></div>
|
||||
</div>
|
||||
{#if !sideImageRemoved}
|
||||
<div class="hidden lg:block h-full">
|
||||
<BrandingImage
|
||||
src={sideImageSrc}
|
||||
alt="login side image"
|
||||
display={sideImageDisplay}
|
||||
boxClass="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
@@ -11,6 +11,7 @@
|
||||
import Reports from './panels/Reports.svelte';
|
||||
import RedTeam from './panels/RedTeam.svelte';
|
||||
import System from './panels/System.svelte';
|
||||
import Branding from './panels/Branding.svelte';
|
||||
|
||||
// Red Team panel is only relevant in red team phishing (blackbox) mode
|
||||
$: tabs = [
|
||||
@@ -22,6 +23,7 @@
|
||||
...($displayMode === DISPLAY_MODE.BLACKBOX
|
||||
? [{ id: 'redteam', label: 'Red Team', component: RedTeam }]
|
||||
: []),
|
||||
{ id: 'branding', label: 'Branding', component: Branding },
|
||||
{ id: 'system', label: 'System', component: System }
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { api } from '$lib/api/apiProxy.js';
|
||||
import { addToast } from '$lib/store/toast';
|
||||
import {
|
||||
branding,
|
||||
loadBranding,
|
||||
brandingImageURL,
|
||||
brandingDisplayFor,
|
||||
BRANDING_SLOT,
|
||||
BRANDING_DEFAULTS,
|
||||
BRANDING_DEFAULT_DISPLAY
|
||||
} from '$lib/store/branding';
|
||||
import SettingsCard from '$lib/components/SettingsCard.svelte';
|
||||
import SettingsLoading from '$lib/components/SettingsLoading.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import FileField from '$lib/components/FileField.svelte';
|
||||
import CheckboxField from '$lib/components/CheckboxField.svelte';
|
||||
import BrandingImage from '$lib/components/BrandingImage.svelte';
|
||||
import BrandingAdjust from '$lib/components/BrandingAdjust.svelte';
|
||||
|
||||
let loaded = false;
|
||||
// per slot busy flag so controls disable during a request
|
||||
let busy = { 'header-logo': false, 'login-logo': false, 'login-side-image': false };
|
||||
// hide state, driven one way from the store and updated by the toggle handler
|
||||
// so it never fights a reactive assignment
|
||||
let sideHidden = false;
|
||||
// local editable copy of the display settings so the preview updates live
|
||||
let forms = { 'header-logo': null, 'login-logo': null, 'login-side-image': null };
|
||||
|
||||
function syncForms() {
|
||||
const b = get(branding);
|
||||
sideHidden = b.loginSideImageHidden;
|
||||
forms = {
|
||||
'header-logo': { ...brandingDisplayFor(b, BRANDING_SLOT.headerLogo) },
|
||||
'login-logo': { ...brandingDisplayFor(b, BRANDING_SLOT.loginLogo) },
|
||||
'login-side-image': { ...brandingDisplayFor(b, BRANDING_SLOT.loginSideImage) }
|
||||
};
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadBranding();
|
||||
syncForms();
|
||||
loaded = true;
|
||||
});
|
||||
|
||||
// preview resolves the image shown in a card for a slot
|
||||
$: headerPreview =
|
||||
$branding.headerLogo === 'custom'
|
||||
? brandingImageURL(BRANDING_SLOT.headerLogo, $branding.version)
|
||||
: BRANDING_DEFAULTS.headerLogo;
|
||||
$: loginPreview =
|
||||
$branding.loginLogo === 'custom'
|
||||
? brandingImageURL(BRANDING_SLOT.loginLogo, $branding.version)
|
||||
: BRANDING_DEFAULTS.loginLogoLight;
|
||||
$: sidePreview =
|
||||
$branding.loginSideImage === 'custom'
|
||||
? brandingImageURL(BRANDING_SLOT.loginSideImage, $branding.version)
|
||||
: BRANDING_DEFAULTS.loginSideImage;
|
||||
|
||||
async function handleUpload(slot, event) {
|
||||
const input = event.target;
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (file.type !== 'image/png') {
|
||||
addToast('Only PNG images are allowed', 'Error');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
busy[slot] = true;
|
||||
try {
|
||||
const res = await api.branding.upload(slot, file);
|
||||
if (!res.success) {
|
||||
addToast(res.error || 'Failed to upload image', 'Error');
|
||||
return;
|
||||
}
|
||||
addToast('Image updated', 'Success');
|
||||
await loadBranding();
|
||||
syncForms();
|
||||
} catch (e) {
|
||||
addToast('Failed to upload image', 'Error');
|
||||
console.error(e);
|
||||
} finally {
|
||||
busy[slot] = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReset(slot) {
|
||||
busy[slot] = true;
|
||||
try {
|
||||
const res = await api.branding.reset(slot);
|
||||
if (!res.success) {
|
||||
addToast(res.error || 'Failed to reset image', 'Error');
|
||||
return;
|
||||
}
|
||||
addToast('Reset to default', 'Success');
|
||||
await loadBranding();
|
||||
syncForms();
|
||||
} catch (e) {
|
||||
addToast('Failed to reset image', 'Error');
|
||||
console.error(e);
|
||||
} finally {
|
||||
busy[slot] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleSideHidden(event) {
|
||||
const hidden = event.target.checked;
|
||||
const slot = BRANDING_SLOT.loginSideImage;
|
||||
busy[slot] = true;
|
||||
try {
|
||||
const res = await api.branding.setSideImageHidden(hidden);
|
||||
if (!res.success) {
|
||||
addToast(res.error || 'Failed to update image', 'Error');
|
||||
} else {
|
||||
addToast(hidden ? 'Login side image hidden' : 'Login side image shown', 'Success');
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to update image', 'Error');
|
||||
console.error(e);
|
||||
} finally {
|
||||
busy[slot] = false;
|
||||
await loadBranding();
|
||||
sideHidden = get(branding).loginSideImageHidden;
|
||||
}
|
||||
}
|
||||
|
||||
// live preview while adjusting
|
||||
function onAdjustInput(slot, event) {
|
||||
forms[slot] = event.detail;
|
||||
forms = forms;
|
||||
}
|
||||
|
||||
// persist a committed adjustment
|
||||
async function onAdjustChange(slot, event) {
|
||||
await persistDisplay(slot, event.detail);
|
||||
}
|
||||
|
||||
// reset the adjustments for a slot back to its defaults
|
||||
async function onAdjustReset(slot) {
|
||||
const def = { ...BRANDING_DEFAULT_DISPLAY[slot] };
|
||||
forms[slot] = def;
|
||||
forms = forms;
|
||||
await persistDisplay(slot, def);
|
||||
}
|
||||
|
||||
async function persistDisplay(slot, display) {
|
||||
forms[slot] = display;
|
||||
forms = forms;
|
||||
busy[slot] = true;
|
||||
try {
|
||||
const res = await api.branding.setDisplay(slot, display);
|
||||
if (!res.success) {
|
||||
addToast(res.error || 'Failed to save adjustment', 'Error');
|
||||
}
|
||||
await loadBranding();
|
||||
forms[slot] = { ...brandingDisplayFor(get(branding), slot) };
|
||||
forms = forms;
|
||||
} catch (e) {
|
||||
addToast('Failed to save adjustment', 'Error');
|
||||
console.error(e);
|
||||
} finally {
|
||||
busy[slot] = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !loaded}
|
||||
<SettingsLoading />
|
||||
{:else}
|
||||
<div class="flex flex-wrap gap-6">
|
||||
<SettingsCard title="Header logo">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4 transition-colors duration-200">
|
||||
Shown in the top navigation. Upload a PNG, ideally with a transparent background.
|
||||
</p>
|
||||
<div class="h-24 rounded-md bg-gray-400 dark:bg-gray-900 mb-2 overflow-hidden">
|
||||
<BrandingImage
|
||||
src={headerPreview}
|
||||
alt="header logo preview"
|
||||
display={forms['header-logo']}
|
||||
boxClass="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<FileField
|
||||
accept="image/png"
|
||||
resets={false}
|
||||
disabled={busy['header-logo']}
|
||||
on:change={(e) => handleUpload(BRANDING_SLOT.headerLogo, e)}
|
||||
/>
|
||||
<BrandingAdjust
|
||||
display={forms['header-logo']}
|
||||
disabled={busy['header-logo']}
|
||||
on:input={(e) => onAdjustInput(BRANDING_SLOT.headerLogo, e)}
|
||||
on:change={(e) => onAdjustChange(BRANDING_SLOT.headerLogo, e)}
|
||||
on:reset={() => onAdjustReset(BRANDING_SLOT.headerLogo)}
|
||||
/>
|
||||
<svelte:fragment slot="footer">
|
||||
{#if $branding.headerLogo === 'custom'}
|
||||
<Button
|
||||
size={'large'}
|
||||
disabled={busy['header-logo']}
|
||||
on:click={() => handleReset(BRANDING_SLOT.headerLogo)}
|
||||
>
|
||||
Reset to default
|
||||
</Button>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Login logo">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4 transition-colors duration-200">
|
||||
Shown on the login screen. Upload a PNG, ideally with a transparent background.
|
||||
</p>
|
||||
<div class="h-24 rounded-md bg-gray-400 dark:bg-gray-900 mb-2 overflow-hidden">
|
||||
<BrandingImage
|
||||
src={loginPreview}
|
||||
alt="login logo preview"
|
||||
display={forms['login-logo']}
|
||||
boxClass="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<FileField
|
||||
accept="image/png"
|
||||
resets={false}
|
||||
disabled={busy['login-logo']}
|
||||
on:change={(e) => handleUpload(BRANDING_SLOT.loginLogo, e)}
|
||||
/>
|
||||
<BrandingAdjust
|
||||
display={forms['login-logo']}
|
||||
disabled={busy['login-logo']}
|
||||
on:input={(e) => onAdjustInput(BRANDING_SLOT.loginLogo, e)}
|
||||
on:change={(e) => onAdjustChange(BRANDING_SLOT.loginLogo, e)}
|
||||
on:reset={() => onAdjustReset(BRANDING_SLOT.loginLogo)}
|
||||
/>
|
||||
<svelte:fragment slot="footer">
|
||||
{#if $branding.loginLogo === 'custom'}
|
||||
<Button
|
||||
size={'large'}
|
||||
disabled={busy['login-logo']}
|
||||
on:click={() => handleReset(BRANDING_SLOT.loginLogo)}
|
||||
>
|
||||
Reset to default
|
||||
</Button>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard title="Login side image">
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm mb-4 transition-colors duration-200">
|
||||
The image beside the login form. Upload a PNG, or hide it to center the login box.
|
||||
</p>
|
||||
<div class="relative h-24 rounded-md bg-gray-400 dark:bg-gray-900 mb-2 overflow-hidden">
|
||||
<BrandingImage
|
||||
src={sidePreview}
|
||||
alt="login side image preview"
|
||||
display={forms['login-side-image']}
|
||||
boxClass="h-full w-full {sideHidden ? 'opacity-30' : ''}"
|
||||
/>
|
||||
{#if sideHidden}
|
||||
<span
|
||||
class="absolute inset-0 flex items-center justify-center text-white text-xs font-semibold"
|
||||
>
|
||||
<span class="px-2 py-1 rounded bg-gray-900/70">Hidden, login box centered</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<FileField
|
||||
accept="image/png"
|
||||
resets={false}
|
||||
disabled={busy['login-side-image']}
|
||||
on:change={(e) => handleUpload(BRANDING_SLOT.loginSideImage, e)}
|
||||
/>
|
||||
<BrandingAdjust
|
||||
display={forms['login-side-image']}
|
||||
disabled={busy['login-side-image']}
|
||||
on:input={(e) => onAdjustInput(BRANDING_SLOT.loginSideImage, e)}
|
||||
on:change={(e) => onAdjustChange(BRANDING_SLOT.loginSideImage, e)}
|
||||
on:reset={() => onAdjustReset(BRANDING_SLOT.loginSideImage)}
|
||||
/>
|
||||
<CheckboxField
|
||||
inline
|
||||
value={sideHidden}
|
||||
disabled={busy['login-side-image']}
|
||||
on:change={handleToggleSideHidden}>Hide image</CheckboxField
|
||||
>
|
||||
<svelte:fragment slot="footer">
|
||||
{#if $branding.loginSideImage === 'custom'}
|
||||
<Button
|
||||
size={'large'}
|
||||
disabled={busy['login-side-image']}
|
||||
on:click={() => handleReset(BRANDING_SLOT.loginSideImage)}
|
||||
>
|
||||
Reset to default
|
||||
</Button>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in new issue
Block a user