mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 14:16:30 +00:00
fix(zalo): address PR #62 review findings — security, reliability, modularity
- Fix CI: use %s for json.Number in e2e test (was %d) - Fix path traversal in parseMediaPath: restrict to os.TempDir() - Add 25MB file size limit (checkFileSize) before upload reads - Drain stale uploadCallbacks on Listener.reset() to prevent leaks - Split send.go (688 LOC) into send.go, send_image.go, send_file.go, send_helpers.go - Add unit tests for parseMediaPath (12 cases incl. traversal attacks)
This commit is contained in:
1 parent
95885da14e
commit
519ecd1eac
8 files changed
+599
-485
No files matched your search
@@ -312,6 +312,18 @@ func (ln *Listener) reset() {
|
||||
ln.client = nil
|
||||
ln.cipherKey = ""
|
||||
ln.connectedAt = time.Time{}
|
||||
|
||||
// Drain pending upload callbacks — old connection callbacks won't arrive.
|
||||
ln.uploadCallbacks.Range(func(key, val any) bool {
|
||||
if ch, ok := val.(chan string); ok {
|
||||
select {
|
||||
case ch <- "":
|
||||
default:
|
||||
}
|
||||
}
|
||||
ln.uploadCallbacks.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (ln *Listener) resetRetryCounters() {
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -181,477 +168,6 @@ func getServiceURL(sess *Session, service string) string {
|
||||
return urls[0]
|
||||
}
|
||||
|
||||
// FlexBool handles JSON fields that may be bool (true/false) or number (0/1).
|
||||
type FlexBool bool
|
||||
|
||||
func (b *FlexBool) UnmarshalJSON(data []byte) error {
|
||||
switch string(data) {
|
||||
case "true", "1":
|
||||
*b = true
|
||||
default:
|
||||
*b = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImageUploadResult holds the response from uploading an image to Zalo's file service.
|
||||
type ImageUploadResult struct {
|
||||
NormalURL string `json:"normalUrl"`
|
||||
HDUrl string `json:"hdUrl"`
|
||||
ThumbURL string `json:"thumbUrl"`
|
||||
PhotoID json.Number `json:"photoId"` // Zalo may return string or number
|
||||
ClientFileID json.Number `json:"clientFileId"` // Zalo may return string or number
|
||||
ChunkID int `json:"chunkId"`
|
||||
Finished FlexBool `json:"finished"` // Zalo returns bool or int depending on endpoint
|
||||
|
||||
// Set by caller (not from API response).
|
||||
Width int `json:"-"`
|
||||
Height int `json:"-"`
|
||||
TotalSize int `json:"-"`
|
||||
}
|
||||
|
||||
// UploadImage uploads an image file to Zalo's file service.
|
||||
// Returns upload metadata needed for SendImage.
|
||||
func UploadImage(ctx context.Context, sess *Session, threadID string, threadType ThreadType, filePath string) (*ImageUploadResult, error) {
|
||||
fileURL := getServiceURL(sess, "file")
|
||||
if fileURL == "" {
|
||||
return nil, fmt.Errorf("zalo_personal: no file service URL")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: read image: %w", err)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
totalSize := len(data)
|
||||
width, height := imageDimensions(data)
|
||||
|
||||
uploadParams := map[string]any{
|
||||
"totalChunk": 1,
|
||||
"fileName": fileName,
|
||||
"clientId": time.Now().UnixMilli(),
|
||||
"totalSize": totalSize,
|
||||
"imei": sess.IMEI,
|
||||
"isE2EE": 0,
|
||||
"jxl": 0,
|
||||
"chunkId": 1,
|
||||
}
|
||||
if threadType == ThreadTypeGroup {
|
||||
uploadParams["grid"] = threadID
|
||||
} else {
|
||||
uploadParams["toid"] = threadID
|
||||
}
|
||||
|
||||
encParams, err := encryptPayload(sess, uploadParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: encrypt upload params: %w", err)
|
||||
}
|
||||
|
||||
// Build URL: file_service/api/{message|group}/photo_original/upload
|
||||
pathPrefix := "/api/message/"
|
||||
typeParam := "2"
|
||||
if threadType == ThreadTypeGroup {
|
||||
pathPrefix = "/api/group/"
|
||||
typeParam = "11"
|
||||
}
|
||||
|
||||
uploadURL := makeURL(sess, fileURL+pathPrefix+"photo_original/upload", map[string]any{
|
||||
"type": typeParam,
|
||||
"params": encParams,
|
||||
}, true)
|
||||
|
||||
// Build multipart body
|
||||
body, contentType, err := buildMultipartBody("chunkContent", fileName, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: build multipart: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setDefaultHeaders(req, sess)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
resp, err := sess.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: upload image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Response: {"error_code":0, "data":"<encrypted>"}
|
||||
var envelope Response[*string]
|
||||
if err := readJSON(resp, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: parse upload response: %w", err)
|
||||
}
|
||||
if envelope.ErrorCode != 0 {
|
||||
return nil, fmt.Errorf("zalo_personal: upload error code %d", envelope.ErrorCode)
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
return nil, fmt.Errorf("zalo_personal: empty upload response")
|
||||
}
|
||||
|
||||
plain, err := decryptDataField(sess, *envelope.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: decrypt upload response: %w", err)
|
||||
}
|
||||
|
||||
var result ImageUploadResult
|
||||
if err := json.Unmarshal(plain, &result); err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: parse upload result: %w", err)
|
||||
}
|
||||
|
||||
result.Width = width
|
||||
result.Height = height
|
||||
result.TotalSize = totalSize
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// SendImage sends a previously uploaded image as a message.
|
||||
func SendImage(ctx context.Context, sess *Session, threadID string, threadType ThreadType, upload *ImageUploadResult, caption string) (string, error) {
|
||||
fileURL := getServiceURL(sess, "file")
|
||||
if fileURL == "" {
|
||||
return "", fmt.Errorf("zalo_personal: no file service URL")
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"photoId": upload.PhotoID,
|
||||
"clientId": strconv.FormatInt(time.Now().UnixMilli(), 10),
|
||||
"desc": caption,
|
||||
"width": upload.Width,
|
||||
"height": upload.Height,
|
||||
"rawUrl": upload.NormalURL,
|
||||
"hdUrl": upload.HDUrl,
|
||||
"thumbUrl": upload.ThumbURL,
|
||||
"hdSize": strconv.Itoa(upload.TotalSize),
|
||||
"zsource": -1,
|
||||
"ttl": 0,
|
||||
"jcp": `{"convertible":"jxl"}`,
|
||||
}
|
||||
if threadType == ThreadTypeGroup {
|
||||
params["grid"] = threadID
|
||||
params["oriUrl"] = upload.NormalURL
|
||||
} else {
|
||||
params["toid"] = threadID
|
||||
params["normalUrl"] = upload.NormalURL
|
||||
}
|
||||
|
||||
encData, err := encryptPayload(sess, params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: encrypt image send params: %w", err)
|
||||
}
|
||||
|
||||
pathPrefix := "/api/message/"
|
||||
if threadType == ThreadTypeGroup {
|
||||
pathPrefix = "/api/group/"
|
||||
}
|
||||
|
||||
sendURL := makeURL(sess, fileURL+pathPrefix+"photo_original/send", map[string]any{"nretry": 0}, true)
|
||||
form := buildFormBody(map[string]string{"params": encData})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, form)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
setDefaultHeaders(req, sess)
|
||||
|
||||
resp, err := sess.Client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: send image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var envelope Response[*string]
|
||||
if err := readJSON(resp, &envelope); err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: parse image send response: %w", err)
|
||||
}
|
||||
if envelope.ErrorCode != 0 {
|
||||
return "", fmt.Errorf("zalo_personal: image send error code %d", envelope.ErrorCode)
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
plain, err := decryptDataField(sess, *envelope.Data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: decrypt image send response: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
MsgID json.Number `json:"msgId"`
|
||||
}
|
||||
if err := json.Unmarshal(plain, &result); err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: parse image send result: %w", err)
|
||||
}
|
||||
return result.MsgID.String(), nil
|
||||
}
|
||||
|
||||
// buildMultipartBody creates a multipart/form-data body with a single file field.
|
||||
func buildMultipartBody(fieldName, fileName string, data []byte) (io.Reader, string, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
|
||||
boundary := "----GoClaw" + randomBoundary()
|
||||
if err := w.SetBoundary(boundary); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
part, err := w.CreateFormFile(fieldName, fileName)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return &buf, w.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
func randomBoundary() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// imageDimensions extracts width and height from an image (PNG, JPEG, etc.).
|
||||
// Returns (0, 0) if the format is unrecognized.
|
||||
func imageDimensions(data []byte) (int, int) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
// IsImageFile returns true if the file extension is a supported image type.
|
||||
func IsImageFile(filePath string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FileUploadResult holds the response from uploading a file to Zalo's file service.
|
||||
type FileUploadResult struct {
|
||||
FileID string `json:"fileId"`
|
||||
FileURL string `json:"fileUrl"` // populated from WS callback
|
||||
ClientFileID json.Number `json:"clientFileId"` // Zalo may return string or number
|
||||
ChunkID int `json:"chunkId"`
|
||||
Finished int `json:"finished"`
|
||||
|
||||
// Set by caller.
|
||||
TotalSize int `json:"-"`
|
||||
FileName string `json:"-"`
|
||||
Checksum string `json:"-"` // MD5 hex
|
||||
}
|
||||
|
||||
// UploadFile uploads a non-image file to Zalo's file service.
|
||||
// The upload response only contains fileId; the fileUrl arrives via WebSocket callback.
|
||||
// The caller must provide the Listener so we can register a callback for the fileUrl.
|
||||
func UploadFile(ctx context.Context, sess *Session, ln *Listener, threadID string, threadType ThreadType, filePath string) (*FileUploadResult, error) {
|
||||
fileURL := getServiceURL(sess, "file")
|
||||
if fileURL == "" {
|
||||
return nil, fmt.Errorf("zalo_personal: no file service URL")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: read file: %w", err)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
totalSize := len(data)
|
||||
clientID := time.Now().UnixMilli()
|
||||
|
||||
uploadParams := map[string]any{
|
||||
"totalChunk": 1,
|
||||
"fileName": fileName,
|
||||
"clientId": clientID,
|
||||
"totalSize": totalSize,
|
||||
"imei": sess.IMEI,
|
||||
"isE2EE": 0,
|
||||
"jxl": 0,
|
||||
"chunkId": 1,
|
||||
}
|
||||
if threadType == ThreadTypeGroup {
|
||||
uploadParams["grid"] = threadID
|
||||
} else {
|
||||
uploadParams["toid"] = threadID
|
||||
}
|
||||
|
||||
encParams, err := encryptPayload(sess, uploadParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: encrypt file upload params: %w", err)
|
||||
}
|
||||
|
||||
pathPrefix := "/api/message/"
|
||||
typeParam := "2"
|
||||
if threadType == ThreadTypeGroup {
|
||||
pathPrefix = "/api/group/"
|
||||
typeParam = "11"
|
||||
}
|
||||
|
||||
uploadURL := makeURL(sess, fileURL+pathPrefix+"asyncfile/upload", map[string]any{
|
||||
"type": typeParam,
|
||||
"params": encParams,
|
||||
}, true)
|
||||
|
||||
body, contentType, err := buildMultipartBody("chunkContent", fileName, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: build multipart: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setDefaultHeaders(req, sess)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
resp, err := sess.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: upload file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var envelope Response[*string]
|
||||
if err := readJSON(resp, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: parse file upload response: %w", err)
|
||||
}
|
||||
if envelope.ErrorCode != 0 {
|
||||
return nil, fmt.Errorf("zalo_personal: file upload error code %d", envelope.ErrorCode)
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
return nil, fmt.Errorf("zalo_personal: empty file upload response")
|
||||
}
|
||||
|
||||
plain, err := decryptDataField(sess, *envelope.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: decrypt file upload response: %w", err)
|
||||
}
|
||||
|
||||
var result FileUploadResult
|
||||
if err := json.Unmarshal(plain, &result); err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: parse file upload result: %w", err)
|
||||
}
|
||||
|
||||
result.TotalSize = totalSize
|
||||
result.FileName = fileName
|
||||
|
||||
// Compute MD5 checksum
|
||||
h := md5Hash(data)
|
||||
result.Checksum = h
|
||||
|
||||
// Wait for fileUrl from WebSocket callback
|
||||
if ln != nil && result.FileID != "" && result.FileID != "-1" {
|
||||
urlCh := ln.RegisterUploadCallback(result.FileID)
|
||||
select {
|
||||
case fileURL := <-urlCh:
|
||||
result.FileURL = fileURL
|
||||
case <-time.After(30 * time.Second):
|
||||
ln.CancelUploadCallback(result.FileID)
|
||||
return nil, fmt.Errorf("zalo_personal: timeout waiting for file upload callback (fileId=%s)", result.FileID)
|
||||
case <-ctx.Done():
|
||||
ln.CancelUploadCallback(result.FileID)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// SendFile sends a previously uploaded file as a message.
|
||||
func SendFile(ctx context.Context, sess *Session, threadID string, threadType ThreadType, upload *FileUploadResult) (string, error) {
|
||||
fileURL := getServiceURL(sess, "file")
|
||||
if fileURL == "" {
|
||||
return "", fmt.Errorf("zalo_personal: no file service URL")
|
||||
}
|
||||
|
||||
ext := strings.TrimPrefix(filepath.Ext(upload.FileName), ".")
|
||||
|
||||
params := map[string]any{
|
||||
"fileId": upload.FileID,
|
||||
"checksum": upload.Checksum,
|
||||
"checksumSha": "",
|
||||
"extention": ext, // Zalo typo: "extention" not "extension"
|
||||
"totalSize": upload.TotalSize,
|
||||
"fileName": upload.FileName,
|
||||
"clientId": upload.ClientFileID.String(),
|
||||
"fType": 1,
|
||||
"fileCount": 0,
|
||||
"fdata": "{}",
|
||||
"fileUrl": upload.FileURL,
|
||||
"zsource": -1,
|
||||
"ttl": 0,
|
||||
}
|
||||
if threadType == ThreadTypeGroup {
|
||||
params["grid"] = threadID
|
||||
} else {
|
||||
params["toid"] = threadID
|
||||
}
|
||||
|
||||
encData, err := encryptPayload(sess, params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: encrypt file send params: %w", err)
|
||||
}
|
||||
|
||||
pathPrefix := "/api/message/"
|
||||
if threadType == ThreadTypeGroup {
|
||||
pathPrefix = "/api/group/"
|
||||
}
|
||||
|
||||
sendURL := makeURL(sess, fileURL+pathPrefix+"asyncfile/msg", map[string]any{"nretry": 0}, true)
|
||||
form := buildFormBody(map[string]string{"params": encData})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, form)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
setDefaultHeaders(req, sess)
|
||||
|
||||
resp, err := sess.Client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: send file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var respEnvelope Response[*string]
|
||||
if err := readJSON(resp, &respEnvelope); err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: parse file send response: %w", err)
|
||||
}
|
||||
if respEnvelope.ErrorCode != 0 {
|
||||
return "", fmt.Errorf("zalo_personal: file send error code %d", respEnvelope.ErrorCode)
|
||||
}
|
||||
if respEnvelope.Data == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
plain, err := decryptDataField(sess, *respEnvelope.Data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: decrypt file send response: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
MsgID json.Number `json:"msgId"`
|
||||
}
|
||||
if err := json.Unmarshal(plain, &result); err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: parse file send result: %w", err)
|
||||
}
|
||||
return result.MsgID.String(), nil
|
||||
}
|
||||
|
||||
// md5Hash returns the MD5 hex digest. Required by Zalo's file upload API checksum field.
|
||||
func md5Hash(data []byte) string {
|
||||
h := md5.Sum(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// encryptPayload encrypts a JSON payload with the session's secret key via AES-CBC.
|
||||
func encryptPayload(sess *Session, payload map[string]any) (string, error) {
|
||||
blob, err := json.Marshal(payload)
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileUploadResult holds the response from uploading a file to Zalo's file service.
|
||||
type FileUploadResult struct {
|
||||
FileID string `json:"fileId"`
|
||||
FileURL string `json:"fileUrl"` // populated from WS callback
|
||||
ClientFileID json.Number `json:"clientFileId"` // Zalo may return string or number
|
||||
ChunkID int `json:"chunkId"`
|
||||
Finished int `json:"finished"`
|
||||
|
||||
// Set by caller.
|
||||
TotalSize int `json:"-"`
|
||||
FileName string `json:"-"`
|
||||
Checksum string `json:"-"` // MD5 hex
|
||||
}
|
||||
|
||||
// UploadFile uploads a non-image file to Zalo's file service.
|
||||
// The upload response only contains fileId; the fileUrl arrives via WebSocket callback.
|
||||
// The caller must provide the Listener so we can register a callback for the fileUrl.
|
||||
func UploadFile(ctx context.Context, sess *Session, ln *Listener, threadID string, threadType ThreadType, filePath string) (*FileUploadResult, error) {
|
||||
fileURL := getServiceURL(sess, "file")
|
||||
if fileURL == "" {
|
||||
return nil, fmt.Errorf("zalo_personal: no file service URL")
|
||||
}
|
||||
|
||||
if err := checkFileSize(filePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: read file: %w", err)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
totalSize := len(data)
|
||||
clientID := time.Now().UnixMilli()
|
||||
|
||||
uploadParams := map[string]any{
|
||||
"totalChunk": 1,
|
||||
"fileName": fileName,
|
||||
"clientId": clientID,
|
||||
"totalSize": totalSize,
|
||||
"imei": sess.IMEI,
|
||||
"isE2EE": 0,
|
||||
"jxl": 0,
|
||||
"chunkId": 1,
|
||||
}
|
||||
if threadType == ThreadTypeGroup {
|
||||
uploadParams["grid"] = threadID
|
||||
} else {
|
||||
uploadParams["toid"] = threadID
|
||||
}
|
||||
|
||||
encParams, err := encryptPayload(sess, uploadParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: encrypt file upload params: %w", err)
|
||||
}
|
||||
|
||||
pathPrefix := "/api/message/"
|
||||
typeParam := "2"
|
||||
if threadType == ThreadTypeGroup {
|
||||
pathPrefix = "/api/group/"
|
||||
typeParam = "11"
|
||||
}
|
||||
|
||||
uploadURL := makeURL(sess, fileURL+pathPrefix+"asyncfile/upload", map[string]any{
|
||||
"type": typeParam,
|
||||
"params": encParams,
|
||||
}, true)
|
||||
|
||||
body, contentType, err := buildMultipartBody("chunkContent", fileName, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: build multipart: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setDefaultHeaders(req, sess)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
resp, err := sess.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: upload file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var envelope Response[*string]
|
||||
if err := readJSON(resp, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: parse file upload response: %w", err)
|
||||
}
|
||||
if envelope.ErrorCode != 0 {
|
||||
return nil, fmt.Errorf("zalo_personal: file upload error code %d", envelope.ErrorCode)
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
return nil, fmt.Errorf("zalo_personal: empty file upload response")
|
||||
}
|
||||
|
||||
plain, err := decryptDataField(sess, *envelope.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: decrypt file upload response: %w", err)
|
||||
}
|
||||
|
||||
var result FileUploadResult
|
||||
if err := json.Unmarshal(plain, &result); err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: parse file upload result: %w", err)
|
||||
}
|
||||
|
||||
result.TotalSize = totalSize
|
||||
result.FileName = fileName
|
||||
|
||||
// Compute MD5 checksum
|
||||
h := md5Hash(data)
|
||||
result.Checksum = h
|
||||
|
||||
// Wait for fileUrl from WebSocket callback
|
||||
if ln != nil && result.FileID != "" && result.FileID != "-1" {
|
||||
urlCh := ln.RegisterUploadCallback(result.FileID)
|
||||
select {
|
||||
case fileURL := <-urlCh:
|
||||
result.FileURL = fileURL
|
||||
case <-time.After(30 * time.Second):
|
||||
ln.CancelUploadCallback(result.FileID)
|
||||
return nil, fmt.Errorf("zalo_personal: timeout waiting for file upload callback (fileId=%s)", result.FileID)
|
||||
case <-ctx.Done():
|
||||
ln.CancelUploadCallback(result.FileID)
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// SendFile sends a previously uploaded file as a message.
|
||||
func SendFile(ctx context.Context, sess *Session, threadID string, threadType ThreadType, upload *FileUploadResult) (string, error) {
|
||||
fileURL := getServiceURL(sess, "file")
|
||||
if fileURL == "" {
|
||||
return "", fmt.Errorf("zalo_personal: no file service URL")
|
||||
}
|
||||
|
||||
ext := strings.TrimPrefix(filepath.Ext(upload.FileName), ".")
|
||||
|
||||
params := map[string]any{
|
||||
"fileId": upload.FileID,
|
||||
"checksum": upload.Checksum,
|
||||
"checksumSha": "",
|
||||
"extention": ext, // Zalo typo: "extention" not "extension"
|
||||
"totalSize": upload.TotalSize,
|
||||
"fileName": upload.FileName,
|
||||
"clientId": upload.ClientFileID.String(),
|
||||
"fType": 1,
|
||||
"fileCount": 0,
|
||||
"fdata": "{}",
|
||||
"fileUrl": upload.FileURL,
|
||||
"zsource": -1,
|
||||
"ttl": 0,
|
||||
}
|
||||
if threadType == ThreadTypeGroup {
|
||||
params["grid"] = threadID
|
||||
} else {
|
||||
params["toid"] = threadID
|
||||
}
|
||||
|
||||
encData, err := encryptPayload(sess, params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: encrypt file send params: %w", err)
|
||||
}
|
||||
|
||||
pathPrefix := "/api/message/"
|
||||
if threadType == ThreadTypeGroup {
|
||||
pathPrefix = "/api/group/"
|
||||
}
|
||||
|
||||
sendURL := makeURL(sess, fileURL+pathPrefix+"asyncfile/msg", map[string]any{"nretry": 0}, true)
|
||||
form := buildFormBody(map[string]string{"params": encData})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, form)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
setDefaultHeaders(req, sess)
|
||||
|
||||
resp, err := sess.Client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: send file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var respEnvelope Response[*string]
|
||||
if err := readJSON(resp, &respEnvelope); err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: parse file send response: %w", err)
|
||||
}
|
||||
if respEnvelope.ErrorCode != 0 {
|
||||
return "", fmt.Errorf("zalo_personal: file send error code %d", respEnvelope.ErrorCode)
|
||||
}
|
||||
if respEnvelope.Data == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
plain, err := decryptDataField(sess, *respEnvelope.Data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: decrypt file send response: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
MsgID json.Number `json:"msgId"`
|
||||
}
|
||||
if err := json.Unmarshal(plain, &result); err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: parse file send result: %w", err)
|
||||
}
|
||||
return result.MsgID.String(), nil
|
||||
}
|
||||
|
||||
// md5Hash returns the MD5 hex digest. Required by Zalo's file upload API checksum field.
|
||||
func md5Hash(data []byte) string {
|
||||
h := md5.Sum(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
)
|
||||
|
||||
// maxUploadSize is the maximum file size for Zalo uploads (25 MB).
|
||||
const maxUploadSize = 25 * 1024 * 1024
|
||||
|
||||
// checkFileSize validates that the file exists and is within the upload size limit.
|
||||
func checkFileSize(filePath string) error {
|
||||
fi, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("zalo_personal: stat file: %w", err)
|
||||
}
|
||||
if fi.Size() > maxUploadSize {
|
||||
return fmt.Errorf("zalo_personal: file too large: %d bytes (max %d)", fi.Size(), maxUploadSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FlexBool handles JSON fields that may be bool (true/false) or number (0/1).
|
||||
type FlexBool bool
|
||||
|
||||
func (b *FlexBool) UnmarshalJSON(data []byte) error {
|
||||
switch string(data) {
|
||||
case "true", "1":
|
||||
*b = true
|
||||
default:
|
||||
*b = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMultipartBody creates a multipart/form-data body with a single file field.
|
||||
func buildMultipartBody(fieldName, fileName string, data []byte) (io.Reader, string, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
|
||||
boundary := "----GoClaw" + randomBoundary()
|
||||
if err := w.SetBoundary(boundary); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
part, err := w.CreateFormFile(fieldName, fileName)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if _, err := part.Write(data); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return &buf, w.FormDataContentType(), nil
|
||||
}
|
||||
|
||||
func randomBoundary() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ImageUploadResult holds the response from uploading an image to Zalo's file service.
|
||||
type ImageUploadResult struct {
|
||||
NormalURL string `json:"normalUrl"`
|
||||
HDUrl string `json:"hdUrl"`
|
||||
ThumbURL string `json:"thumbUrl"`
|
||||
PhotoID json.Number `json:"photoId"` // Zalo may return string or number
|
||||
ClientFileID json.Number `json:"clientFileId"` // Zalo may return string or number
|
||||
ChunkID int `json:"chunkId"`
|
||||
Finished FlexBool `json:"finished"` // Zalo returns bool or int depending on endpoint
|
||||
|
||||
// Set by caller (not from API response).
|
||||
Width int `json:"-"`
|
||||
Height int `json:"-"`
|
||||
TotalSize int `json:"-"`
|
||||
}
|
||||
|
||||
// UploadImage uploads an image file to Zalo's file service.
|
||||
// Returns upload metadata needed for SendImage.
|
||||
func UploadImage(ctx context.Context, sess *Session, threadID string, threadType ThreadType, filePath string) (*ImageUploadResult, error) {
|
||||
fileURL := getServiceURL(sess, "file")
|
||||
if fileURL == "" {
|
||||
return nil, fmt.Errorf("zalo_personal: no file service URL")
|
||||
}
|
||||
|
||||
if err := checkFileSize(filePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: read image: %w", err)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
totalSize := len(data)
|
||||
width, height := imageDimensions(data)
|
||||
|
||||
uploadParams := map[string]any{
|
||||
"totalChunk": 1,
|
||||
"fileName": fileName,
|
||||
"clientId": time.Now().UnixMilli(),
|
||||
"totalSize": totalSize,
|
||||
"imei": sess.IMEI,
|
||||
"isE2EE": 0,
|
||||
"jxl": 0,
|
||||
"chunkId": 1,
|
||||
}
|
||||
if threadType == ThreadTypeGroup {
|
||||
uploadParams["grid"] = threadID
|
||||
} else {
|
||||
uploadParams["toid"] = threadID
|
||||
}
|
||||
|
||||
encParams, err := encryptPayload(sess, uploadParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: encrypt upload params: %w", err)
|
||||
}
|
||||
|
||||
// Build URL: file_service/api/{message|group}/photo_original/upload
|
||||
pathPrefix := "/api/message/"
|
||||
typeParam := "2"
|
||||
if threadType == ThreadTypeGroup {
|
||||
pathPrefix = "/api/group/"
|
||||
typeParam = "11"
|
||||
}
|
||||
|
||||
uploadURL := makeURL(sess, fileURL+pathPrefix+"photo_original/upload", map[string]any{
|
||||
"type": typeParam,
|
||||
"params": encParams,
|
||||
}, true)
|
||||
|
||||
// Build multipart body
|
||||
body, contentType, err := buildMultipartBody("chunkContent", fileName, data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: build multipart: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setDefaultHeaders(req, sess)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
resp, err := sess.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: upload image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Response: {"error_code":0, "data":"<encrypted>"}
|
||||
var envelope Response[*string]
|
||||
if err := readJSON(resp, &envelope); err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: parse upload response: %w", err)
|
||||
}
|
||||
if envelope.ErrorCode != 0 {
|
||||
return nil, fmt.Errorf("zalo_personal: upload error code %d", envelope.ErrorCode)
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
return nil, fmt.Errorf("zalo_personal: empty upload response")
|
||||
}
|
||||
|
||||
plain, err := decryptDataField(sess, *envelope.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: decrypt upload response: %w", err)
|
||||
}
|
||||
|
||||
var result ImageUploadResult
|
||||
if err := json.Unmarshal(plain, &result); err != nil {
|
||||
return nil, fmt.Errorf("zalo_personal: parse upload result: %w", err)
|
||||
}
|
||||
|
||||
result.Width = width
|
||||
result.Height = height
|
||||
result.TotalSize = totalSize
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// SendImage sends a previously uploaded image as a message.
|
||||
func SendImage(ctx context.Context, sess *Session, threadID string, threadType ThreadType, upload *ImageUploadResult, caption string) (string, error) {
|
||||
fileURL := getServiceURL(sess, "file")
|
||||
if fileURL == "" {
|
||||
return "", fmt.Errorf("zalo_personal: no file service URL")
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"photoId": upload.PhotoID,
|
||||
"clientId": strconv.FormatInt(time.Now().UnixMilli(), 10),
|
||||
"desc": caption,
|
||||
"width": upload.Width,
|
||||
"height": upload.Height,
|
||||
"rawUrl": upload.NormalURL,
|
||||
"hdUrl": upload.HDUrl,
|
||||
"thumbUrl": upload.ThumbURL,
|
||||
"hdSize": strconv.Itoa(upload.TotalSize),
|
||||
"zsource": -1,
|
||||
"ttl": 0,
|
||||
"jcp": `{"convertible":"jxl"}`,
|
||||
}
|
||||
if threadType == ThreadTypeGroup {
|
||||
params["grid"] = threadID
|
||||
params["oriUrl"] = upload.NormalURL
|
||||
} else {
|
||||
params["toid"] = threadID
|
||||
params["normalUrl"] = upload.NormalURL
|
||||
}
|
||||
|
||||
encData, err := encryptPayload(sess, params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: encrypt image send params: %w", err)
|
||||
}
|
||||
|
||||
pathPrefix := "/api/message/"
|
||||
if threadType == ThreadTypeGroup {
|
||||
pathPrefix = "/api/group/"
|
||||
}
|
||||
|
||||
sendURL := makeURL(sess, fileURL+pathPrefix+"photo_original/send", map[string]any{"nretry": 0}, true)
|
||||
form := buildFormBody(map[string]string{"params": encData})
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, form)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
setDefaultHeaders(req, sess)
|
||||
|
||||
resp, err := sess.Client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: send image: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var envelope Response[*string]
|
||||
if err := readJSON(resp, &envelope); err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: parse image send response: %w", err)
|
||||
}
|
||||
if envelope.ErrorCode != 0 {
|
||||
return "", fmt.Errorf("zalo_personal: image send error code %d", envelope.ErrorCode)
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
plain, err := decryptDataField(sess, *envelope.Data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: decrypt image send response: %w", err)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
MsgID json.Number `json:"msgId"`
|
||||
}
|
||||
if err := json.Unmarshal(plain, &result); err != nil {
|
||||
return "", fmt.Errorf("zalo_personal: parse image send result: %w", err)
|
||||
}
|
||||
return result.MsgID.String(), nil
|
||||
}
|
||||
|
||||
// imageDimensions extracts width and height from an image (PNG, JPEG, etc.).
|
||||
// Returns (0, 0) if the format is unrecognized.
|
||||
func imageDimensions(data []byte) (int, int) {
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, 0
|
||||
}
|
||||
return cfg.Width, cfg.Height
|
||||
}
|
||||
|
||||
// IsImageFile returns true if the file extension is a supported image type.
|
||||
func IsImageFile(filePath string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".webp":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -138,6 +138,7 @@ func (t *MessageTool) sendMedia(ctx context.Context, channel, target, filePath s
|
||||
}
|
||||
|
||||
// parseMediaPath extracts a file path from a "MEDIA:/path/to/file" string.
|
||||
// Only allows absolute paths within os.TempDir() to prevent path traversal.
|
||||
func parseMediaPath(s string) (string, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if !strings.HasPrefix(s, "MEDIA:") {
|
||||
@@ -147,5 +148,13 @@ func parseMediaPath(s string) (string, bool) {
|
||||
if path == "" || path == "." {
|
||||
return "", false
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
return "", false
|
||||
}
|
||||
// Restrict to temp directory to prevent path traversal.
|
||||
tmpDir := filepath.Clean(os.TempDir())
|
||||
if !strings.HasPrefix(path, tmpDir+string(filepath.Separator)) && path != tmpDir {
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMediaPath(t *testing.T) {
|
||||
tmpDir := os.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"valid temp file", "MEDIA:" + filepath.Join(tmpDir, "test.png"), filepath.Join(tmpDir, "test.png"), true},
|
||||
{"valid nested temp file", "MEDIA:" + filepath.Join(tmpDir, "sub", "file.txt"), filepath.Join(tmpDir, "sub", "file.txt"), true},
|
||||
{"with spaces around prefix", " MEDIA:" + filepath.Join(tmpDir, "test.png") + " ", filepath.Join(tmpDir, "test.png"), true},
|
||||
{"no prefix", filepath.Join(tmpDir, "test.png"), "", false},
|
||||
{"empty after prefix", "MEDIA:", "", false},
|
||||
{"relative path", "MEDIA:relative/path.txt", "", false},
|
||||
{"dot path", "MEDIA:.", "", false},
|
||||
{"outside temp dir", "MEDIA:/etc/passwd", "", false},
|
||||
{"traversal attack", "MEDIA:" + filepath.Join(tmpDir, "..", "etc", "passwd"), "", false},
|
||||
{"home directory", "MEDIA:" + filepath.Join(os.Getenv("HOME"), "secret.txt"), "", false},
|
||||
{"empty string", "", "", false},
|
||||
{"just MEDIA", "MEDIA", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseMediaPath(tt.input)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("parseMediaPath(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("parseMediaPath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("upload image: %v", err)
|
||||
}
|
||||
log.Printf("Upload OK: photoId=%d normalUrl=%s", upload.PhotoID, truncate(upload.NormalURL, 60))
|
||||
log.Printf("Upload OK: photoId=%s normalUrl=%s", upload.PhotoID.String(), truncate(upload.NormalURL, 60))
|
||||
|
||||
imgMsgID, err := protocol.SendImage(ctx, sess, groupID, protocol.ThreadTypeGroup, upload, "Go E2E test image")
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user