初始化仓库
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
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"
|
||||
}
|
||||
Reference in New Issue
Block a user