332 lines
10 KiB
Go
332 lines
10 KiB
Go
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"
|
||
|
|
}
|
||
|
|
}
|