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.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user