package support import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "regexp" "strings" "github.com/google/uuid" ) const ( maxKBImageBytes = 2 << 20 // 2 MiB kbMediaSubdir = "support-kb" // KBImageAdminURLPrefix is the platform-admin serve path returned after upload. KBImageAdminURLPrefix = "/api/admin/support/kb/images/" // KBImagePublicPathPrefix is the permanent public serve path (HMAC-signed). KBImagePublicPathPrefix = "/api/public/support-kb/" ) var ( ErrKBImageInvalidType = errors.New("image must be PNG, JPEG, or WebP") ErrKBImageTooLarge = errors.New("image exceeds 2 MiB limit") ErrKBImageInvalidName = errors.New("invalid image filename") ErrKBImageNotFound = errors.New("image not found") ErrKBImageForbidden = errors.New("image access forbidden") ErrKBImageBadSig = errors.New("invalid image signature") ErrKBUploadDirMissing = errors.New("upload directory not configured") kbImageNameRE = 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)$`) ) // KBImageUpload is the admin upload response (insert markdown_url into body_md). type KBImageUpload struct { Filename string `json:"filename"` ContentType string `json:"content_type"` Size int64 `json:"size"` AdminURL string `json:"admin_url"` MarkdownURL string `json:"markdown_url"` Markdown string `json:"markdown"` } type kbImageKind struct { ext string contentType string } // SaveKBImage validates mime/size and stores under UPLOAD_DIR/support-kb (not web-executable). func SaveKBImage(uploadDir, publicAPIURL, signingSecret, originalName, declaredType string, r io.Reader) (KBImageUpload, error) { uploadDir = strings.TrimSpace(uploadDir) if uploadDir == "" { return KBImageUpload{}, ErrKBUploadDirMissing } limited := io.LimitReader(r, maxKBImageBytes+1) data, err := io.ReadAll(limited) if err != nil { return KBImageUpload{}, err } if int64(len(data)) > maxKBImageBytes { return KBImageUpload{}, ErrKBImageTooLarge } kind, err := detectKBImage(data, originalName, declaredType) if err != nil { return KBImageUpload{}, err } fileID := uuid.New() name := strings.ToLower(fileID.String() + "." + kind.ext) dir := filepath.Join(uploadDir, kbMediaSubdir) if err := os.MkdirAll(dir, 0o750); err != nil { return KBImageUpload{}, err } abs := filepath.Join(dir, name) if err := os.WriteFile(abs, data, 0o640); err != nil { return KBImageUpload{}, err } mdURL, err := PublicKBImageURL(publicAPIURL, signingSecret, name) if err != nil { _ = os.Remove(abs) return KBImageUpload{}, err } adminURL := KBImageAdminURLPrefix + name return KBImageUpload{ Filename: name, ContentType: kind.contentType, Size: int64(len(data)), AdminURL: adminURL, MarkdownURL: mdURL, Markdown: fmt.Sprintf("![image](%s)", mdURL), }, nil } // ResolveKBImagePath returns the absolute filesystem path for a KB image. func ResolveKBImagePath(uploadDir, name string) (string, error) { name, err := sanitizeKBImageName(name) if err != nil { return "", err } uploadDir = strings.TrimSpace(uploadDir) if uploadDir == "" { return "", ErrKBUploadDirMissing } base := filepath.Join(uploadDir, kbMediaSubdir) abs := filepath.Join(base, name) rel, err := filepath.Rel(base, abs) if err != nil || strings.HasPrefix(rel, "..") { return "", ErrKBImageForbidden } return abs, nil } // OpenKBImage opens a stored KB image for reading. func OpenKBImage(uploadDir, name string) (*os.File, string, error) { abs, err := ResolveKBImagePath(uploadDir, name) if err != nil { return nil, "", err } f, err := os.Open(abs) if err != nil { if os.IsNotExist(err) { return nil, "", ErrKBImageNotFound } return nil, "", err } return f, contentTypeForKBImageName(name), nil } // PublicKBImageURL builds a permanent absolute URL with HMAC signature (no expiry). func PublicKBImageURL(publicAPIURL, secret, filename string) (string, error) { filename, err := sanitizeKBImageName(filename) if err != nil { return "", err } secret = strings.TrimSpace(secret) if secret == "" { return "", errors.New("token signing secret not configured") } sig := signKBImage(secret, filename) base := strings.TrimRight(strings.TrimSpace(publicAPIURL), "/") if base == "" { base = "http://localhost:28471" } q := url.Values{} q.Set("sig", sig) return fmt.Sprintf("%s%s%s?%s", base, KBImagePublicPathPrefix, filename, q.Encode()), nil } // VerifyKBImageSig checks the permanent HMAC for a public KB image request. func VerifyKBImageSig(secret, filename, sig string) error { filename, err := sanitizeKBImageName(filename) if err != nil { return err } if strings.TrimSpace(secret) == "" || strings.TrimSpace(sig) == "" { return ErrKBImageBadSig } expected := signKBImage(secret, filename) if !hmac.Equal([]byte(expected), []byte(strings.TrimSpace(sig))) { return ErrKBImageBadSig } return nil } func signKBImage(secret, filename string) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write([]byte("kb|" + filename)) return hex.EncodeToString(mac.Sum(nil)) } func sanitizeKBImageName(name string) (string, error) { name = filepath.Base(strings.TrimSpace(name)) if name == "" || name == "." || name == ".." { return "", ErrKBImageInvalidName } if strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) { return "", ErrKBImageInvalidName } if !kbImageNameRE.MatchString(name) { return "", ErrKBImageInvalidName } return strings.ToLower(name), nil } func detectKBImage(data []byte, originalName, declaredType string) (kbImageKind, error) { if len(data) < 12 { return kbImageKind{}, ErrKBImageInvalidType } 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 kbImageKind{}, ErrKBImageInvalidType } if extFromName != "" && extFromName != ".png" { return kbImageKind{}, ErrKBImageInvalidType } return kbImageKind{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 kbImageKind{}, ErrKBImageInvalidType } if extFromName != "" && extFromName != ".jpg" && extFromName != ".jpeg" { return kbImageKind{}, ErrKBImageInvalidType } return kbImageKind{ext: "jpg", contentType: "image/jpeg"}, nil case isKBWebP(data): if declared != "" && !strings.Contains(declared, "webp") && declared != "application/octet-stream" { return kbImageKind{}, ErrKBImageInvalidType } if extFromName != "" && extFromName != ".webp" { return kbImageKind{}, ErrKBImageInvalidType } return kbImageKind{ext: "webp", contentType: "image/webp"}, nil default: _ = http.DetectContentType(data) return kbImageKind{}, ErrKBImageInvalidType } } func isKBWebP(data []byte) bool { return len(data) >= 12 && bytes.Equal(data[0:4], []byte("RIFF")) && bytes.Equal(data[8:12], []byte("WEBP")) } func contentTypeForKBImageName(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" } } // MediaClientError maps KB image errors for HTTP responses. func MediaClientError(err error) (msg string, ok bool) { switch { case err == nil: return "", false case errors.Is(err, ErrKBImageInvalidType), errors.Is(err, ErrKBImageTooLarge), errors.Is(err, ErrKBImageInvalidName), errors.Is(err, ErrKBImageBadSig), errors.Is(err, ErrKBUploadDirMissing): return err.Error(), true default: return "", false } }