268 lines
7.9 KiB
Go
268 lines
7.9 KiB
Go
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("", 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
|
||
|
|
}
|
||
|
|
}
|