Browse Source

feat: send image from URL (#237)

* feat: support send image from url

* fix: improve image upload validation and handling

- Add URL validation for image URL in backend
- Enhance frontend image upload logic to handle optional file and URL inputs
- Remove debug print statement in send validation

* docs: updapte openapi

* feat: update libs

* feat: update version

* feat: enhance image download and validation

- Add max image size limit from config
- Improve image download with timeout and redirect handling
- Validate content type and file size during image download
- Update test cases to use httptest for mocking HTTP servers

* fix: revert code
pull/239/head v5.1.0
Aldino Kemal 1 year ago
committed by GitHub
parent
commit
0947c6746f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 21
      docs/openapi.yaml
  2. 3
      src/config/settings.go
  3. 2
      src/domains/send/audio.go
  4. 1
      src/domains/send/image.go
  5. 6
      src/go.mod
  6. 6
      src/go.sum
  7. 5
      src/internal/rest/send.go
  8. 48
      src/pkg/utils/general.go
  9. 88
      src/pkg/utils/general_test.go
  10. 23
      src/services/send.go
  11. 36
      src/validations/send_validation.go
  12. 187
      src/validations/send_validation_test.go
  13. 20
      src/views/components/SendImage.js

21
docs/openapi.yaml

@ -1,7 +1,7 @@
openapi: "3.0.0" openapi: "3.0.0"
info: info:
title: WhatsApp API MultiDevice title: WhatsApp API MultiDevice
version: 5.0.0
version: 5.1.0
description: This API is used for sending whatsapp via API description: This API is used for sending whatsapp via API
servers: servers:
- url: http://localhost:3000 - url: http://localhost:3000
@ -131,8 +131,9 @@ paths:
- name: phone - name: phone
in: query in: query
schema: schema:
type: integer
type: string
example: '6289685028129@s.whatsapp.net' example: '6289685028129@s.whatsapp.net'
description: Phone number with country code
responses: responses:
'200': '200':
description: OK description: OK
@ -162,13 +163,15 @@ paths:
- name: phone - name: phone
in: query in: query
schema: schema:
type: integer
type: string
example: '6289685028129@s.whatsapp.net' example: '6289685028129@s.whatsapp.net'
description: Phone number with country code
- name: is_preview - name: is_preview
in: query in: query
schema: schema:
type: boolean type: boolean
example: true example: true
description: Whether to fetch a preview of the avatar
responses: responses:
'200': '200':
description: OK description: OK
@ -350,6 +353,10 @@ paths:
type: string type: string
format: binary format: binary
description: Image to send description: Image to send
image_url:
type: string
example: https://example.com/image.jpg
description: Image URL to send
compress: compress:
type: boolean type: boolean
example: false example: false
@ -1320,13 +1327,17 @@ components:
type: number type: number
UserInfoResponse: UserInfoResponse:
type: object type: object
required:
- code
- message
- results
properties: properties:
code: code:
type: string type: string
example: SUCCESS example: SUCCESS
message: message:
type: string type: string
example:
example: Success
results: results:
type: object type: object
properties: properties:
@ -1574,7 +1585,7 @@ components:
properties: properties:
text: text:
type: string type: string
example: "WhatsApps official channel. Follow for our latest feature launches, updates, exclusive drops and more."
example: "WhatsApp's official channel. Follow for our latest feature launches, updates, exclusive drops and more."
id: id:
type: string type: string
example: "1689653839450668" example: "1689653839450668"

3
src/config/settings.go

@ -5,7 +5,7 @@ import (
) )
var ( var (
AppVersion = "v5.0.0"
AppVersion = "v5.1.0"
AppPort = "3000" AppPort = "3000"
AppDebug = false AppDebug = false
AppOs = "AldinoKemal" AppOs = "AldinoKemal"
@ -24,6 +24,7 @@ var (
WhatsappWebhook []string WhatsappWebhook []string
WhatsappWebhookSecret = "secret" WhatsappWebhookSecret = "secret"
WhatsappLogLevel = "ERROR" WhatsappLogLevel = "ERROR"
WhatsappSettingMaxImageSize int64 = 20000000 // 20MB
WhatsappSettingMaxFileSize int64 = 50000000 // 50MB WhatsappSettingMaxFileSize int64 = 50000000 // 50MB
WhatsappSettingMaxVideoSize int64 = 100000000 // 100MB WhatsappSettingMaxVideoSize int64 = 100000000 // 100MB
WhatsappSettingMaxDownloadSize int64 = 500000000 // 500MB WhatsappSettingMaxDownloadSize int64 = 500000000 // 500MB

2
src/domains/send/audio.go

@ -4,5 +4,5 @@ import "mime/multipart"
type AudioRequest struct { type AudioRequest struct {
Phone string `json:"phone" form:"phone"` Phone string `json:"phone" form:"phone"`
Audio *multipart.FileHeader `json:"Audio" form:"Audio"`
Audio *multipart.FileHeader `json:"audio" form:"audio"`
} }

1
src/domains/send/image.go

@ -6,6 +6,7 @@ type ImageRequest struct {
Phone string `json:"phone" form:"phone"` Phone string `json:"phone" form:"phone"`
Caption string `json:"caption" form:"caption"` Caption string `json:"caption" form:"caption"`
Image *multipart.FileHeader `json:"image" form:"image"` Image *multipart.FileHeader `json:"image" form:"image"`
ImageURL *string `json:"image_url" form:"image_url"`
ViewOnce bool `json:"view_once" form:"view_once"` ViewOnce bool `json:"view_once" form:"view_once"`
Compress bool `json:"compress"` Compress bool `json:"compress"`
} }

6
src/go.mod

@ -21,7 +21,7 @@ require (
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/valyala/fasthttp v1.58.0 github.com/valyala/fasthttp v1.58.0
go.mau.fi/libsignal v0.1.1 go.mau.fi/libsignal v0.1.1
go.mau.fi/whatsmeow v0.0.0-20250130221717-faf72d668860
go.mau.fi/whatsmeow v0.0.0-20250204095649-a75587ab11d7
google.golang.org/protobuf v1.36.4 google.golang.org/protobuf v1.36.4
) )
@ -50,9 +50,9 @@ require (
github.com/valyala/tcplisten v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect
go.mau.fi/util v0.8.4 // indirect go.mau.fi/util v0.8.4 // indirect
golang.org/x/crypto v0.32.0 // indirect golang.org/x/crypto v0.32.0 // indirect
golang.org/x/image v0.23.0 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.34.0 // indirect golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/sys v0.30.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

6
src/go.sum

@ -105,6 +105,8 @@ go.mau.fi/whatsmeow v0.0.0-20250104105216-918c879fcd19 h1:uVS+Zct5fF8rSXV9lfs87z
go.mau.fi/whatsmeow v0.0.0-20250104105216-918c879fcd19/go.mod h1:TLzm2XkwgufONEmiVAsFny+9uBqyEZnUoPrQAfMyuSU= go.mau.fi/whatsmeow v0.0.0-20250104105216-918c879fcd19/go.mod h1:TLzm2XkwgufONEmiVAsFny+9uBqyEZnUoPrQAfMyuSU=
go.mau.fi/whatsmeow v0.0.0-20250130221717-faf72d668860 h1:jQhAJJGC42rwZ562nz6V9SXBCVz+QhORusd0r9cxiww= go.mau.fi/whatsmeow v0.0.0-20250130221717-faf72d668860 h1:jQhAJJGC42rwZ562nz6V9SXBCVz+QhORusd0r9cxiww=
go.mau.fi/whatsmeow v0.0.0-20250130221717-faf72d668860/go.mod h1:PG1x7fBW66I9q/e8a9mU2qF9M94+kK32MceMWgxBoiw= go.mau.fi/whatsmeow v0.0.0-20250130221717-faf72d668860/go.mod h1:PG1x7fBW66I9q/e8a9mU2qF9M94+kK32MceMWgxBoiw=
go.mau.fi/whatsmeow v0.0.0-20250204095649-a75587ab11d7 h1:eLT0TKTpSeNcszoyasSylaxFCNU82XCP72DjbInFpzg=
go.mau.fi/whatsmeow v0.0.0-20250204095649-a75587ab11d7/go.mod h1:PG1x7fBW66I9q/e8a9mU2qF9M94+kK32MceMWgxBoiw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@ -116,6 +118,8 @@ golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ug
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -155,6 +159,8 @@ golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=

5
src/internal/rest/send.go

@ -52,9 +52,10 @@ func (controller *Send) SendImage(c *fiber.Ctx) error {
utils.PanicIfNeeded(err) utils.PanicIfNeeded(err)
file, err := c.FormFile("image") file, err := c.FormFile("image")
utils.PanicIfNeeded(err)
if err == nil {
request.Image = file request.Image = file
}
whatsapp.SanitizePhone(&request.Phone) whatsapp.SanitizePhone(&request.Phone)
response, err := controller.Service.SendImage(c.UserContext(), request) response, err := controller.Service.SendImage(c.UserContext(), request)

48
src/pkg/utils/general.go

@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/PuerkitoBio/goquery" "github.com/PuerkitoBio/goquery"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config"
) )
// RemoveFile is removing file with delay // RemoveFile is removing file with delay
@ -131,3 +132,50 @@ func ContainsMention(message string) []string {
} }
return phoneNumbers return phoneNumbers
} }
func DownloadImageFromURL(url string) ([]byte, string, error) {
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
return nil
},
}
response, err := client.Get(url)
if err != nil {
return nil, "", err
}
defer response.Body.Close()
contentType := response.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
return nil, "", fmt.Errorf("invalid content type: %s", contentType)
}
// Check content length if available
if contentLength := response.ContentLength; contentLength > int64(config.WhatsappSettingMaxImageSize) {
return nil, "", fmt.Errorf("image size %d exceeds maximum allowed size %d", contentLength, config.WhatsappSettingMaxImageSize)
}
// Limit the size from config
reader := io.LimitReader(response.Body, int64(config.WhatsappSettingMaxImageSize))
// Extract the file name from the URL and remove query parameters if present
segments := strings.Split(url, "/")
fileName := segments[len(segments)-1]
fileName = strings.Split(fileName, "?")[0]
// Check if the file extension is supported
allowedExtensions := map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".webp": true,
}
extension := strings.ToLower(filepath.Ext(fileName))
if !allowedExtensions[extension] {
return nil, "", fmt.Errorf("unsupported file type: %s", extension)
}
imageData, err := io.ReadAll(reader)
if err != nil {
return nil, "", err
}
return imageData, fileName, nil
}

88
src/pkg/utils/general_test.go

@ -1,12 +1,21 @@
package utils_test package utils_test
import ( import (
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils" "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/utils"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"testing"
"github.com/stretchr/testify/suite"
) )
func TestContainsMention(t *testing.T) {
type UtilsTestSuite struct {
suite.Suite
}
func (suite *UtilsTestSuite) TestContainsMention() {
type args struct { type args struct {
message string message string
} }
@ -32,9 +41,82 @@ func TestContainsMention(t *testing.T) {
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
suite.T().Run(tt.name, func(t *testing.T) {
got := utils.ContainsMention(tt.args.message) got := utils.ContainsMention(tt.args.message)
assert.Equal(t, tt.want, got) assert.Equal(t, tt.want, got)
}) })
} }
} }
func (suite *UtilsTestSuite) TestRemoveFile() {
tempFile, err := os.CreateTemp("", "testfile")
assert.NoError(suite.T(), err)
tempFilePath := tempFile.Name()
tempFile.Close()
err = utils.RemoveFile(0, tempFilePath)
assert.NoError(suite.T(), err)
_, err = os.Stat(tempFilePath)
assert.True(suite.T(), os.IsNotExist(err))
}
func (suite *UtilsTestSuite) TestCreateFolder() {
tempDir := "testdir"
err := utils.CreateFolder(tempDir)
assert.NoError(suite.T(), err)
_, err = os.Stat(tempDir)
assert.NoError(suite.T(), err)
assert.True(suite.T(), err == nil)
os.RemoveAll(tempDir)
}
func (suite *UtilsTestSuite) TestPanicIfNeeded() {
assert.PanicsWithValue(suite.T(), "test error", func() {
utils.PanicIfNeeded("test error")
})
assert.NotPanics(suite.T(), func() {
utils.PanicIfNeeded(nil)
})
}
func (suite *UtilsTestSuite) TestStrToFloat64() {
assert.Equal(suite.T(), 123.45, utils.StrToFloat64("123.45"))
assert.Equal(suite.T(), 0.0, utils.StrToFloat64("invalid"))
assert.Equal(suite.T(), 0.0, utils.StrToFloat64(""))
}
func (suite *UtilsTestSuite) TestGetMetaDataFromURL() {
// Use httptest.NewServer to mock HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`<!DOCTYPE html><html><head><title>Test Title</title><meta name='description' content='Test Description'><meta property='og:image' content='http://example.com/image.jpg'></head><body></body></html>`))
}))
defer server.Close() // Ensure the server is closed when the test ends
meta := utils.GetMetaDataFromURL(server.URL)
assert.Equal(suite.T(), "Test Title", meta.Title)
assert.Equal(suite.T(), "Test Description", meta.Description)
assert.Equal(suite.T(), "http://example.com/image.jpg", meta.Image)
}
func (suite *UtilsTestSuite) TestDownloadImageFromURL() {
// Use httptest.NewServer to mock HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/image.jpg" {
w.Header().Set("Content-Type", "image/jpeg") // Set content type to image
w.Write([]byte("image data"))
} else {
http.NotFound(w, r)
}
}))
defer server.Close() // Ensure the server is closed when the test ends
imageData, fileName, err := utils.DownloadImageFromURL(server.URL + "/image.jpg")
assert.NoError(suite.T(), err)
assert.Equal(suite.T(), []byte("image data"), imageData)
assert.Equal(suite.T(), "image.jpg", fileName)
}
func TestUtilsTestSuite(t *testing.T) {
suite.Run(t, new(UtilsTestSuite))
}

23
src/services/send.go

@ -119,15 +119,32 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima
var ( var (
imagePath string imagePath string
imageThumbnail string imageThumbnail string
imageName string
deletedItems []string deletedItems []string
oriImagePath string
) )
if request.ImageURL != nil && *request.ImageURL != "" {
// Download image from URL
imageData, fileName, err := utils.DownloadImageFromURL(*request.ImageURL)
oriImagePath = fmt.Sprintf("%s/%s", config.PathSendItems, fileName)
if err != nil {
return response, pkgError.InternalServerError(fmt.Sprintf("failed to download image from URL %v", err))
}
imageName = fileName
err = os.WriteFile(oriImagePath, imageData, 0644)
if err != nil {
return response, pkgError.InternalServerError(fmt.Sprintf("failed to save downloaded image %v", err))
}
} else if request.Image != nil {
// Save image to server // Save image to server
oriImagePath := fmt.Sprintf("%s/%s", config.PathSendItems, request.Image.Filename)
oriImagePath = fmt.Sprintf("%s/%s", config.PathSendItems, request.Image.Filename)
err = fasthttp.SaveMultipartFile(request.Image, oriImagePath) err = fasthttp.SaveMultipartFile(request.Image, oriImagePath)
if err != nil { if err != nil {
return response, err return response, err
} }
imageName = request.Image.Filename
}
deletedItems = append(deletedItems, oriImagePath) deletedItems = append(deletedItems, oriImagePath)
/* Generate thumbnail with smalled image size */ /* Generate thumbnail with smalled image size */
@ -138,7 +155,7 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima
// Resize Thumbnail // Resize Thumbnail
resizedImage := imaging.Resize(srcImage, 100, 0, imaging.Lanczos) resizedImage := imaging.Resize(srcImage, 100, 0, imaging.Lanczos)
imageThumbnail = fmt.Sprintf("%s/thumbnails-%s", config.PathSendItems, request.Image.Filename)
imageThumbnail = fmt.Sprintf("%s/thumbnails-%s", config.PathSendItems, imageName)
if err = imaging.Save(resizedImage, imageThumbnail); err != nil { if err = imaging.Save(resizedImage, imageThumbnail); err != nil {
return response, pkgError.InternalServerError(fmt.Sprintf("failed to save thumbnail %v", err)) return response, pkgError.InternalServerError(fmt.Sprintf("failed to save thumbnail %v", err))
} }
@ -151,7 +168,7 @@ func (service serviceSend) SendImage(ctx context.Context, request domainSend.Ima
return response, pkgError.InternalServerError(fmt.Sprintf("failed to open image %v", err)) return response, pkgError.InternalServerError(fmt.Sprintf("failed to open image %v", err))
} }
newImage := imaging.Resize(openImageBuffer, 600, 0, imaging.Lanczos) newImage := imaging.Resize(openImageBuffer, 600, 0, imaging.Lanczos)
newImagePath := fmt.Sprintf("%s/new-%s", config.PathSendItems, request.Image.Filename)
newImagePath := fmt.Sprintf("%s/new-%s", config.PathSendItems, imageName)
if err = imaging.Save(newImage, newImagePath); err != nil { if err = imaging.Save(newImage, newImagePath); err != nil {
return response, pkgError.InternalServerError(fmt.Sprintf("failed to save image %v", err)) return response, pkgError.InternalServerError(fmt.Sprintf("failed to save image %v", err))
} }

36
src/validations/send_validation.go

@ -3,6 +3,7 @@ package validations
import ( import (
"context" "context"
"fmt" "fmt"
"sort"
"github.com/aldinokemal/go-whatsapp-web-multidevice/config" "github.com/aldinokemal/go-whatsapp-web-multidevice/config"
domainSend "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/send" domainSend "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/send"
@ -27,13 +28,17 @@ func ValidateSendMessage(ctx context.Context, request domainSend.MessageRequest)
func ValidateSendImage(ctx context.Context, request domainSend.ImageRequest) error { func ValidateSendImage(ctx context.Context, request domainSend.ImageRequest) error {
err := validation.ValidateStructWithContext(ctx, &request, err := validation.ValidateStructWithContext(ctx, &request,
validation.Field(&request.Phone, validation.Required), validation.Field(&request.Phone, validation.Required),
validation.Field(&request.Image, validation.Required),
) )
if err != nil { if err != nil {
return pkgError.ValidationError(err.Error()) return pkgError.ValidationError(err.Error())
} }
if request.Image == nil && (request.ImageURL == nil || *request.ImageURL == "") {
return pkgError.ValidationError("either Image or ImageURL must be provided")
}
if request.Image != nil {
availableMimes := map[string]bool{ availableMimes := map[string]bool{
"image/jpeg": true, "image/jpeg": true,
"image/jpg": true, "image/jpg": true,
@ -43,6 +48,18 @@ func ValidateSendImage(ctx context.Context, request domainSend.ImageRequest) err
if !availableMimes[request.Image.Header.Get("Content-Type")] { if !availableMimes[request.Image.Header.Get("Content-Type")] {
return pkgError.ValidationError("your image is not allowed. please use jpg/jpeg/png") return pkgError.ValidationError("your image is not allowed. please use jpg/jpeg/png")
} }
}
if request.ImageURL != nil {
if *request.ImageURL == "" {
return pkgError.ValidationError("ImageURL cannot be empty")
}
err := validation.Validate(*request.ImageURL, is.URL)
if err != nil {
return pkgError.ValidationError("ImageURL must be a valid URL")
}
}
return nil return nil
} }
@ -154,10 +171,8 @@ func ValidateSendAudio(ctx context.Context, request domainSend.AudioRequest) err
"audio/mp3": true, "audio/mp3": true,
"audio/mpeg": true, "audio/mpeg": true,
"audio/ogg": true, "audio/ogg": true,
"audio/wma": true, "audio/wma": true,
"audio/x-ms-wma": true, "audio/x-ms-wma": true,
"audio/wav": true, "audio/wav": true,
"audio/vnd.wav": true, "audio/vnd.wav": true,
"audio/vnd.wave": true, "audio/vnd.wave": true,
@ -166,7 +181,15 @@ func ValidateSendAudio(ctx context.Context, request domainSend.AudioRequest) err
"audio/x-wav": true, "audio/x-wav": true,
} }
availableMimesStr := "" availableMimesStr := ""
// Sort MIME types for consistent error message order
mimeKeys := make([]string, 0, len(availableMimes))
for k := range availableMimes { for k := range availableMimes {
mimeKeys = append(mimeKeys, k)
}
sort.Strings(mimeKeys)
for _, k := range mimeKeys {
availableMimesStr += k + "," availableMimesStr += k + ","
} }
@ -178,11 +201,15 @@ func ValidateSendAudio(ctx context.Context, request domainSend.AudioRequest) err
} }
func ValidateSendPoll(ctx context.Context, request domainSend.PollRequest) error { func ValidateSendPoll(ctx context.Context, request domainSend.PollRequest) error {
// Validate options first to ensure it is not blank before validating MaxAnswer
if len(request.Options) == 0 {
return pkgError.ValidationError("options: cannot be blank.")
}
err := validation.ValidateStructWithContext(ctx, &request, err := validation.ValidateStructWithContext(ctx, &request,
validation.Field(&request.Phone, validation.Required), validation.Field(&request.Phone, validation.Required),
validation.Field(&request.Question, validation.Required), validation.Field(&request.Question, validation.Required),
validation.Field(&request.Options, validation.Required),
validation.Field(&request.Options, validation.Each(validation.Required)), validation.Field(&request.Options, validation.Each(validation.Required)),
validation.Field(&request.MaxAnswer, validation.Required), validation.Field(&request.MaxAnswer, validation.Required),
@ -204,7 +231,6 @@ func ValidateSendPoll(ctx context.Context, request domainSend.PollRequest) error
} }
return nil return nil
} }
func ValidateSendPresence(ctx context.Context, request domainSend.PresenceRequest) error { func ValidateSendPresence(ctx context.Context, request domainSend.PresenceRequest) error {

187
src/validations/send_validation_test.go

@ -2,12 +2,13 @@ package validations
import ( import (
"context" "context"
"mime/multipart"
"testing"
domainMessage "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/message" domainMessage "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/message"
domainSend "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/send" domainSend "github.com/aldinokemal/go-whatsapp-web-multidevice/domains/send"
pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error" pkgError "github.com/aldinokemal/go-whatsapp-web-multidevice/pkg/error"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"mime/multipart"
"testing"
) )
func TestValidateSendMessage(t *testing.T) { func TestValidateSendMessage(t *testing.T) {
@ -91,7 +92,7 @@ func TestValidateSendImage(t *testing.T) {
Phone: "1728937129312@s.whatsapp.net", Phone: "1728937129312@s.whatsapp.net",
Image: nil, Image: nil,
}}, }},
err: pkgError.ValidationError("image: cannot be blank."),
err: pkgError.ValidationError("either Image or ImageURL must be provided"),
}, },
{ {
name: "should error with invalid image type", name: "should error with invalid image type",
@ -528,3 +529,183 @@ func TestValidateSendLocation(t *testing.T) {
}) })
} }
} }
func TestValidateSendAudio(t *testing.T) {
audio := &multipart.FileHeader{
Filename: "sample-audio.mp3",
Size: 100,
Header: map[string][]string{"Content-Type": {"audio/mp3"}},
}
type args struct {
request domainSend.AudioRequest
}
tests := []struct {
name string
args args
err any
}{
{
name: "should success with normal condition",
args: args{request: domainSend.AudioRequest{
Phone: "1728937129312@s.whatsapp.net",
Audio: audio,
}},
err: nil,
},
{
name: "should error with empty phone",
args: args{request: domainSend.AudioRequest{
Phone: "",
Audio: audio,
}},
err: pkgError.ValidationError("phone: cannot be blank."),
},
{
name: "should error with empty audio",
args: args{request: domainSend.AudioRequest{
Phone: "1728937129312@s.whatsapp.net",
Audio: nil,
}},
err: pkgError.ValidationError("audio: cannot be blank."),
},
{
name: "should error with invalid audio type",
args: args{request: domainSend.AudioRequest{
Phone: "1728937129312@s.whatsapp.net",
Audio: &multipart.FileHeader{
Filename: "sample-audio.txt",
Size: 100,
Header: map[string][]string{"Content-Type": {"text/plain"}},
},
}},
err: pkgError.ValidationError("your audio type is not allowed. please use (audio/aac,audio/amr,audio/flac,audio/m4a,audio/m4r,audio/mp3,audio/mpeg,audio/ogg,audio/vnd.wav,audio/vnd.wave,audio/wav,audio/wave,audio/wma,audio/x-ms-wma,audio/x-pn-wav,audio/x-wav,)"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSendAudio(context.Background(), tt.args.request)
assert.Equal(t, tt.err, err)
})
}
}
func TestValidateSendPoll(t *testing.T) {
type args struct {
request domainSend.PollRequest
}
tests := []struct {
name string
args args
err any
}{
{
name: "should success with normal condition",
args: args{request: domainSend.PollRequest{
Phone: "1728937129312@s.whatsapp.net",
Question: "What is your favorite color?",
Options: []string{"Red", "Blue", "Green"},
MaxAnswer: 1,
}},
err: nil,
},
{
name: "should error with empty phone",
args: args{request: domainSend.PollRequest{
Phone: "",
Question: "What is your favorite color?",
Options: []string{"Red", "Blue", "Green"},
MaxAnswer: 1,
}},
err: pkgError.ValidationError("phone: cannot be blank."),
},
{
name: "should error with empty question",
args: args{request: domainSend.PollRequest{
Phone: "1728937129312@s.whatsapp.net",
Question: "",
Options: []string{"Red", "Blue", "Green"},
MaxAnswer: 1,
}},
err: pkgError.ValidationError("question: cannot be blank."),
},
{
name: "should error with empty options",
args: args{request: domainSend.PollRequest{
Phone: "1728937129312@s.whatsapp.net",
Question: "What is your favorite color?",
Options: []string{},
MaxAnswer: 5,
}},
err: pkgError.ValidationError("options: cannot be blank."),
},
{
name: "should error with duplicate options",
args: args{request: domainSend.PollRequest{
Phone: "1728937129312@s.whatsapp.net",
Question: "What is your favorite color?",
Options: []string{"Red", "Red", "Green"},
MaxAnswer: 1,
}},
err: pkgError.ValidationError("options should be unique"),
},
{
name: "should error with max answer greater than options",
args: args{request: domainSend.PollRequest{
Phone: "1728937129312@s.whatsapp.net",
Question: "What is your favorite color?",
Options: []string{"Red", "Blue", "Green"},
MaxAnswer: 5,
}},
err: pkgError.ValidationError("max_answer: must be no greater than 3."),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSendPoll(context.Background(), tt.args.request)
assert.Equal(t, tt.err, err)
})
}
}
func TestValidateSendPresence(t *testing.T) {
type args struct {
request domainSend.PresenceRequest
}
tests := []struct {
name string
args args
err any
}{
{
name: "should success with available type",
args: args{request: domainSend.PresenceRequest{
Type: "available",
}},
err: nil,
},
{
name: "should success with unavailable type",
args: args{request: domainSend.PresenceRequest{
Type: "unavailable",
}},
err: nil,
},
{
name: "should error with invalid type",
args: args{request: domainSend.PresenceRequest{
Type: "invalid",
}},
err: pkgError.ValidationError("type: must be a valid value."),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSendPresence(context.Background(), tt.args.request)
assert.Equal(t, tt.err, err)
})
}
}

20
src/views/components/SendImage.js

@ -14,6 +14,7 @@ export default {
type: window.TYPEUSER, type: window.TYPEUSER,
loading: false, loading: false,
selected_file: null, selected_file: null,
image_url: null,
preview_url: null preview_url: null
} }
}, },
@ -38,7 +39,7 @@ export default {
return false; return false;
} }
if (!this.selected_file) {
if (!this.selected_file && !this.image_url) {
return false; return false;
} }
@ -65,7 +66,15 @@ export default {
payload.append("view_once", this.view_once) payload.append("view_once", this.view_once)
payload.append("compress", this.compress) payload.append("compress", this.compress)
payload.append("caption", this.caption) payload.append("caption", this.caption)
payload.append('image', $("#file_image")[0].files[0])
const fileInput = $("#file_image");
if (fileInput.length > 0 && fileInput[0].files.length > 0) {
const file = fileInput[0].files[0];
payload.append('image', file);
}
if (this.image_url) {
payload.append('image_url', this.image_url)
}
let response = await window.http.post(`/send/image`, payload) let response = await window.http.post(`/send/image`, payload)
this.handleReset(); this.handleReset();
@ -86,6 +95,7 @@ export default {
this.caption = ''; this.caption = '';
this.preview_url = null; this.preview_url = null;
this.selected_file = null; this.selected_file = null;
this.image_url = null;
$("#file_image").val(''); $("#file_image").val('');
}, },
handleImageChange(event) { handleImageChange(event) {
@ -145,6 +155,12 @@ export default {
<label>Check for compressing image to smaller size</label> <label>Check for compressing image to smaller size</label>
</div> </div>
</div> </div>
<div class="field">
<label>Image URL</label>
<input type="text" v-model="image_url" placeholder="https://example.com/image.jpg"
aria-label="image_url"/>
</div>
<div style="text-align: left; font-weight: bold; margin: 10px 0;">or you can upload image from your device</div>
<div class="field" style="padding-bottom: 30px"> <div class="field" style="padding-bottom: 30px">
<label>Image</label> <label>Image</label>
<input type="file" style="display: none" id="file_image" accept="image/png,image/jpg,image/jpeg" @change="handleImageChange"/> <input type="file" style="display: none" id="file_image" accept="image/png,image/jpg,image/jpeg" @change="handleImageChange"/>

Loading…
Cancel
Save