mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-10-04 14:36:50 +02:00
412 lines
13 KiB
Go
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
|
|
}
|