Files
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

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"
}
}