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:
viettranx committed 2026-03-06 23:21:16 +07:00
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
}
+9
View File
@@ -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
}
+43
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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 {