package company import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/descrybe/descrybe-v2/apps/api/internal/security" "github.com/google/uuid" ) const ( maxBrandLogoBytes = 2 << 20 // 2 MiB brandLogoSubdir = "brand" // BrandLogoURLPrefix is the authenticated same-origin path stored in logo_url. BrandLogoURLPrefix = "/api/brand/logo/files/" // PublicBrandLogoPathPrefix is the signed public serve path. PublicBrandLogoPathPrefix = "/api/public/brand-logo/" ) var ( ErrLogoInvalidType = errors.New("logo must be PNG, JPEG, or WebP") ErrLogoTooLarge = errors.New("logo exceeds 2 MiB limit") ErrLogoInvalidName = errors.New("invalid logo filename") ErrLogoNotFound = errors.New("logo not found") ErrLogoForbidden = errors.New("logo access forbidden") ErrLogoBadSig = errors.New("invalid or expired logo signature") brandLogoNameRE = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.(png|jpe?g|webp)$`) ) // ClientError reports whether err is a known client-facing brand logo validation error. func ClientError(err error) (msg string, ok bool) { switch { case err == nil: return "", false case errors.Is(err, ErrLogoInvalidType), errors.Is(err, ErrLogoTooLarge): return err.Error(), true default: return "", false } } type brandLogoKind struct { ext string contentType string } // SaveBrandLogo stores a validated logo under company uploads and returns the served relative URL. func SaveBrandLogo(uploadDir string, companyID uuid.UUID, originalName, declaredType string, r io.Reader) (logoURL, absPath, contentType string, size int64, err error) { uploadDir = strings.TrimSpace(uploadDir) if uploadDir == "" { return "", "", "", 0, errors.New("upload directory not configured") } limited := io.LimitReader(r, maxBrandLogoBytes+1) data, err := io.ReadAll(limited) if err != nil { return "", "", "", 0, err } if int64(len(data)) > maxBrandLogoBytes { return "", "", "", 0, ErrLogoTooLarge } kind, err := detectBrandLogo(data, originalName, declaredType) if err != nil { return "", "", "", 0, err } fileID := uuid.New() name := fileID.String() + "." + kind.ext dir := filepath.Join(uploadDir, companyID.String(), brandLogoSubdir) if err := os.MkdirAll(dir, 0o750); err != nil { return "", "", "", 0, err } abs := filepath.Join(dir, name) if err := os.WriteFile(abs, data, 0o640); err != nil { return "", "", "", 0, err } return BrandLogoURLPrefix + name, abs, kind.contentType, int64(len(data)), nil } // ResolveBrandLogoPath returns the absolute filesystem path for a company logo file. func ResolveBrandLogoPath(uploadDir string, companyID uuid.UUID, name string) (string, error) { name, err := sanitizeBrandLogoName(name) if err != nil { return "", err } uploadDir = strings.TrimSpace(uploadDir) if uploadDir == "" { return "", errors.New("upload directory not configured") } abs := filepath.Join(uploadDir, companyID.String(), brandLogoSubdir, name) // Ensure resolved path stays under the company brand dir (no symlink escape). base := filepath.Join(uploadDir, companyID.String(), brandLogoSubdir) rel, err := filepath.Rel(base, abs) if err != nil || strings.HasPrefix(rel, "..") { return "", ErrLogoForbidden } return abs, nil } // OpenBrandLogo opens a company-scoped logo for reading. func OpenBrandLogo(uploadDir string, companyID uuid.UUID, name string) (*os.File, string, error) { abs, err := ResolveBrandLogoPath(uploadDir, companyID, name) if err != nil { return nil, "", err } f, err := os.Open(abs) if err != nil { if os.IsNotExist(err) { return nil, "", ErrLogoNotFound } return nil, "", err } ct := contentTypeForLogoName(name) return f, ct, nil } // ValidateLogoURL accepts empty, public HTTPS logos, or company-hosted brand logo paths. func ValidateLogoURL(raw string, companyID uuid.UUID) (string, error) { raw = strings.TrimSpace(raw) if raw == "" { return "", nil } if strings.HasPrefix(raw, BrandLogoURLPrefix) { name := strings.TrimPrefix(raw, BrandLogoURLPrefix) if _, err := sanitizeBrandLogoName(name); err != nil { return "", security.ErrInvalidURL } if strings.Contains(name, "/") || strings.Contains(name, `\`) { return "", security.ErrInvalidURL } return BrandLogoURLPrefix + name, nil } // Absolute PublicAPIURL forms of hosted logos → normalize to relative path. if u, err := url.Parse(raw); err == nil && u.IsAbs() { path := u.Path if strings.HasPrefix(path, BrandLogoURLPrefix) { name := strings.TrimPrefix(path, BrandLogoURLPrefix) if _, err := sanitizeBrandLogoName(name); err != nil { return "", security.ErrInvalidURL } return BrandLogoURLPrefix + name, nil } if strings.HasPrefix(path, PublicBrandLogoPathPrefix) { rest := strings.TrimPrefix(path, PublicBrandLogoPathPrefix) parts := strings.Split(strings.Trim(rest, "/"), "/") if len(parts) == 2 { cid, err := uuid.Parse(parts[0]) if err != nil || cid != companyID { return "", security.ErrInvalidURL } if _, err := sanitizeBrandLogoName(parts[1]); err != nil { return "", security.ErrInvalidURL } return BrandLogoURLPrefix + parts[1], nil } } } return security.ValidatePublicHTTPSURL(raw) } // HostedLogoFilename extracts the filename from a hosted brand logo_url. func HostedLogoFilename(logoURL string) (string, bool) { logoURL = strings.TrimSpace(logoURL) if !strings.HasPrefix(logoURL, BrandLogoURLPrefix) { return "", false } name := strings.TrimPrefix(logoURL, BrandLogoURLPrefix) if _, err := sanitizeBrandLogoName(name); err != nil { return "", false } return name, true } // SignPublicBrandLogoURL builds a time-limited absolute URL for emails / public embeds. func SignPublicBrandLogoURL(publicAPIURL, secret string, companyID uuid.UUID, filename string, ttl time.Duration) (string, error) { filename, err := sanitizeBrandLogoName(filename) if err != nil { return "", err } secret = strings.TrimSpace(secret) if secret == "" { return "", errors.New("token signing secret not configured") } if ttl <= 0 { ttl = 7 * 24 * time.Hour } exp := time.Now().Add(ttl).Unix() sig := signBrandLogo(secret, companyID, filename, exp) base := strings.TrimRight(strings.TrimSpace(publicAPIURL), "/") if base == "" { base = "http://localhost:8080" } q := url.Values{} q.Set("exp", strconv.FormatInt(exp, 10)) q.Set("sig", sig) return fmt.Sprintf("%s%s%s/%s?%s", base, PublicBrandLogoPathPrefix, companyID.String(), filename, q.Encode()), nil } // VerifyPublicBrandLogoSig checks exp+sig for a public brand logo request. func VerifyPublicBrandLogoSig(secret string, companyID uuid.UUID, filename string, exp int64, sig string) error { filename, err := sanitizeBrandLogoName(filename) if err != nil { return err } if strings.TrimSpace(secret) == "" || strings.TrimSpace(sig) == "" { return ErrLogoBadSig } if exp <= 0 || time.Now().Unix() > exp { return ErrLogoBadSig } expected := signBrandLogo(secret, companyID, filename, exp) if !hmac.Equal([]byte(expected), []byte(strings.TrimSpace(sig))) { return ErrLogoBadSig } return nil } // AbsoluteLogoForEmbed returns an absolute URL suitable for email/HTML embeds. // Hosted logos become signed public URLs; external HTTPS URLs are returned as-is. func AbsoluteLogoForEmbed(publicAPIURL, secret string, companyID uuid.UUID, logoURL string) string { logoURL = strings.TrimSpace(logoURL) if logoURL == "" { return "" } if name, ok := HostedLogoFilename(logoURL); ok { signed, err := SignPublicBrandLogoURL(publicAPIURL, secret, companyID, name, 30*24*time.Hour) if err != nil { return "" } return signed } if strings.HasPrefix(strings.ToLower(logoURL), "https://") || strings.HasPrefix(strings.ToLower(logoURL), "http://") { return logoURL } return "" } func signBrandLogo(secret string, companyID uuid.UUID, filename string, exp int64) string { payload := companyID.String() + "|" + filename + "|" + strconv.FormatInt(exp, 10) mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte(payload)) return hex.EncodeToString(mac.Sum(nil)) } func sanitizeBrandLogoName(name string) (string, error) { name = filepath.Base(strings.TrimSpace(name)) if name == "" || name == "." || name == ".." { return "", ErrLogoInvalidName } if strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) { return "", ErrLogoInvalidName } if !brandLogoNameRE.MatchString(name) { return "", ErrLogoInvalidName } return strings.ToLower(name), nil } func detectBrandLogo(data []byte, originalName, declaredType string) (brandLogoKind, error) { if len(data) < 12 { return brandLogoKind{}, ErrLogoInvalidType } ct := http.DetectContentType(data) extFromName := strings.ToLower(filepath.Ext(originalName)) declared := strings.ToLower(strings.TrimSpace(declaredType)) switch { case bytes.HasPrefix(data, []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}): if declared != "" && !strings.Contains(declared, "png") && declared != "application/octet-stream" { return brandLogoKind{}, ErrLogoInvalidType } if extFromName != "" && extFromName != ".png" { return brandLogoKind{}, ErrLogoInvalidType } return brandLogoKind{ext: "png", contentType: "image/png"}, nil case bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}): if declared != "" && !strings.Contains(declared, "jpeg") && !strings.Contains(declared, "jpg") && declared != "application/octet-stream" { return brandLogoKind{}, ErrLogoInvalidType } if extFromName != "" && extFromName != ".jpg" && extFromName != ".jpeg" { return brandLogoKind{}, ErrLogoInvalidType } return brandLogoKind{ext: "jpg", contentType: "image/jpeg"}, nil case isWebP(data): if declared != "" && !strings.Contains(declared, "webp") && declared != "application/octet-stream" { return brandLogoKind{}, ErrLogoInvalidType } if extFromName != "" && extFromName != ".webp" { return brandLogoKind{}, ErrLogoInvalidType } return brandLogoKind{ext: "webp", contentType: "image/webp"}, nil default: _ = ct return brandLogoKind{}, ErrLogoInvalidType } } func isWebP(data []byte) bool { return len(data) >= 12 && bytes.Equal(data[0:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")) } func contentTypeForLogoName(name string) string { switch strings.ToLower(filepath.Ext(name)) { case ".png": return "image/png" case ".jpg", ".jpeg": return "image/jpeg" case ".webp": return "image/webp" default: return "application/octet-stream" } }