package weixin import ( "bytes" "context" "crypto/aes" "crypto/md5" "encoding/base64" "encoding/hex" "fmt" "io" "log/slog" "net/http" "net/url" "regexp" "strings" ) const maxWeixinMediaBytes = 100 << 20 var hex32RE = regexp.MustCompile(`^[0-9a-fA-F]{32}$`) // aesECBPaddedSize returns ciphertext length for AES-128-ECB with PKCS#7 padding. func aesECBPaddedSize(plaintextLen int) int { if plaintextLen < 0 { return 0 } return ((plaintextLen + aes.BlockSize) / aes.BlockSize) * aes.BlockSize } func pkcs7Pad(b []byte, blockSize int) []byte { if blockSize <= 0 || blockSize > 255 { panic("invalid block size") } n := blockSize - (len(b) % blockSize) pad := bytes.Repeat([]byte{byte(n)}, n) return append(b, pad...) } func pkcs7Unpad(b []byte, blockSize int) ([]byte, error) { if len(b) == 0 || len(b)%blockSize != 0 { return nil, fmt.Errorf("invalid padded length %d", len(b)) } n := int(b[len(b)-1]) if n == 0 || n > blockSize || n > len(b) { return nil, fmt.Errorf("invalid pkcs7 padding") } for i := len(b) - n; i < len(b); i++ { if b[i] != byte(n) { return nil, fmt.Errorf("invalid pkcs7 padding") } } return b[:len(b)-n], nil } func encryptAESECB(plaintext, key []byte) ([]byte, error) { if len(key) != 16 { return nil, fmt.Errorf("aes key must be 16 bytes, got %d", len(key)) } block, err := aes.NewCipher(key) if err != nil { return nil, err } padded := pkcs7Pad(plaintext, aes.BlockSize) out := make([]byte, len(padded)) for i := 0; i < len(padded); i += aes.BlockSize { block.Encrypt(out[i:i+aes.BlockSize], padded[i:i+aes.BlockSize]) } return out, nil } func decryptAESECB(ciphertext, key []byte) ([]byte, error) { if len(key) != 16 { return nil, fmt.Errorf("aes key must be 16 bytes, got %d", len(key)) } if len(ciphertext)%aes.BlockSize != 0 { return nil, fmt.Errorf("ciphertext length %d not aligned to block", len(ciphertext)) } block, err := aes.NewCipher(key) if err != nil { return nil, err } out := make([]byte, len(ciphertext)) for i := 0; i < len(ciphertext); i += aes.BlockSize { block.Decrypt(out[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize]) } return pkcs7Unpad(out, aes.BlockSize) } // parseAesKey decodes CDNMedia.aes_key: base64(raw 16 bytes) or base64(32-char hex ASCII) → 16 bytes. func parseAesKey(aesKeyBase64, label string) ([]byte, error) { decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(aesKeyBase64)) if err != nil { return nil, fmt.Errorf("%s: aes_key base64: %w", label, err) } if len(decoded) == 16 { return decoded, nil } if len(decoded) == 32 { s := string(decoded) if hex32RE.MatchString(s) { k, err := hex.DecodeString(s) if err != nil { return nil, fmt.Errorf("%s: aes_key hex inside base64: %w", label, err) } return k, nil } } return nil, fmt.Errorf("%s: aes_key must be 16 raw bytes or 32-char hex (base64-wrapped), got %d bytes after base64", label, len(decoded)) } func buildCdnDownloadURL(encryptedQueryParam, cdnBase string) string { return fmt.Sprintf("%s/download?encrypted_query_param=%s", strings.TrimRight(cdnBase, "/"), url.QueryEscape(encryptedQueryParam)) } func buildCdnUploadURL(cdnBase, uploadParam, filekey string) string { return fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s", strings.TrimRight(cdnBase, "/"), url.QueryEscape(uploadParam), url.QueryEscape(filekey)) } func fetchCdnBytes(ctx context.Context, client *http.Client, fullURL, label string) ([]byte, error) { if client == nil { client = http.DefaultClient } req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) if err != nil { return nil, fmt.Errorf("%s: new request: %w", label, err) } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("%s: get: %w", label, err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, maxWeixinMediaBytes+1)) if err != nil { return nil, fmt.Errorf("%s: read: %w", label, err) } if len(body) > maxWeixinMediaBytes { return nil, fmt.Errorf("%s: CDN body exceeds %d bytes", label, maxWeixinMediaBytes) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("%s: CDN http %d: %s", label, resp.StatusCode, truncateForLog(body, 256)) } return body, nil } func downloadAndDecryptCDN(ctx context.Context, client *http.Client, cdnBase, encParam, aesKeyBase64, label string) ([]byte, error) { key, err := parseAesKey(aesKeyBase64, label) if err != nil { return nil, err } u := buildCdnDownloadURL(encParam, cdnBase) enc, err := fetchCdnBytes(ctx, client, u, label) if err != nil { return nil, err } plain, err := decryptAESECB(enc, key) if err != nil { return nil, fmt.Errorf("%s: decrypt: %w", label, err) } return plain, nil } func downloadPlainCDN(ctx context.Context, client *http.Client, cdnBase, encParam, label string) ([]byte, error) { u := buildCdnDownloadURL(encParam, cdnBase) return fetchCdnBytes(ctx, client, u, label) } const cdnUploadMaxRetries = 3 // uploadBufferToCDN encrypts plaintext with AES-128-ECB and uploads to the given CDN URL. // Caller is responsible for building the full URL (via buildCdnUploadURL or from upload_full_url). func uploadBufferToCDN(ctx context.Context, client *http.Client, cdnURL string, plaintext, aesKey []byte, label string) (downloadParam string, err error) { ciphertext, err := encryptAESECB(plaintext, aesKey) if err != nil { return "", fmt.Errorf("%s: encrypt: %w", label, err) } u := cdnURL var lastErr error for attempt := 1; attempt <= cdnUploadMaxRetries; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(ciphertext)) if err != nil { return "", fmt.Errorf("%s: new request: %w", label, err) } req.Header.Set("Content-Type", "application/octet-stream") resp, err := client.Do(req) if err != nil { lastErr = err slog.Warn("weixin: CDN upload request failed", "label", label, "attempt", attempt, "error", err) continue } _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) _ = resp.Body.Close() if resp.StatusCode >= 400 && resp.StatusCode < 500 { msg := resp.Header.Get("x-error-message") if msg == "" { msg = resp.Status } return "", fmt.Errorf("%s: CDN upload client error %d: %s", label, resp.StatusCode, msg) } if resp.StatusCode != http.StatusOK { msg := resp.Header.Get("x-error-message") if msg == "" { msg = fmt.Sprintf("status %d", resp.StatusCode) } lastErr = fmt.Errorf("%s: CDN upload server error: %s", label, msg) slog.Warn("weixin: CDN upload server error", "label", label, "attempt", attempt, "error", lastErr) continue } dl := resp.Header.Get("x-encrypted-param") if dl == "" { lastErr = fmt.Errorf("%s: CDN response missing x-encrypted-param", label) slog.Warn("weixin: CDN upload bad response", "label", label, "attempt", attempt) continue } return dl, nil } if lastErr != nil { return "", fmt.Errorf("%s: CDN upload failed after %d attempts: %w", label, cdnUploadMaxRetries, lastErr) } return "", fmt.Errorf("%s: CDN upload failed after %d attempts", label, cdnUploadMaxRetries) } func md5Hex(b []byte) string { h := md5.Sum(b) return hex.EncodeToString(h[:]) } func detectImageMime(b []byte) string { if len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF { return "image/jpeg" } if len(b) >= 8 && string(b[0:8]) == "\x89PNG\r\n\x1a\n" { return "image/png" } if len(b) >= 6 && (string(b[0:6]) == "GIF87a" || string(b[0:6]) == "GIF89a") { return "image/gif" } if len(b) >= 12 && string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP" { return "image/webp" } return "image/jpeg" }