Files
2026-09-17 21:56:06 +02:00

412 lines
13 KiB
Go

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
}