Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions internal/bootstrap/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -501,3 +501,20 @@ func registerThirdTikTokRoutes(group *gin.RouterGroup) {
group.POST("/webhook", third.TikTokPostWebhook)
group.POST("/webhook/:channel_id", third.TikTokPostWebhook)
}

func registerThirdLineRoutes(group *gin.RouterGroup) {
group.POST("/webhook", third.LinePostWebhook)
group.POST("/webhook/:channel_id", third.LinePostWebhook)
}

func registerThirdViberRoutes(group *gin.RouterGroup) {
group.POST("/webhook", third.ViberPostWebhook)
group.POST("/webhook/:channel_id", third.ViberPostWebhook)
}

func registerThirdThreadsRoutes(group *gin.RouterGroup) {
group.GET("/webhook", third.ThreadsGetWebhook)
group.GET("/webhook/:channel_id", third.ThreadsGetWebhook)
group.POST("/webhook", third.ThreadsPostWebhook)
group.POST("/webhook/:channel_id", third.ThreadsPostWebhook)
}
3 changes: 3 additions & 0 deletions internal/bootstrap/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ func addRouter(app *gin.Engine) {
registerThirdSlackRoutes(thirdGroup.Group("/slack"))
registerThirdXRoutes(thirdGroup.Group("/x"))
registerThirdTikTokRoutes(thirdGroup.Group("/tiktok"))
registerThirdLineRoutes(thirdGroup.Group("/line"))
registerThirdViberRoutes(thirdGroup.Group("/viber"))
registerThirdThreadsRoutes(thirdGroup.Group("/threads"))
}

type spaShellRewrite struct {
Expand Down
36 changes: 36 additions & 0 deletions internal/handlers/third/line_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package third

import (
"bytes"
"io"
"net/http"
"strings"

"agent-desk/internal/services"

"github.com/gin-gonic/gin"
)

// LinePostWebhook receives incoming webhook events from the LINE Platform.
func LinePostWebhook(ctx *gin.Context) {
channelID := strings.TrimSpace(ctx.Param("channel_id"))
if channelID == "" {
channelID = strings.TrimSpace(ctx.Query("channel_id"))
}

signature := ctx.GetHeader("X-Line-Signature")

bodyBytes, err := io.ReadAll(ctx.Request.Body)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "failed to read body"})
return
}
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))

if err := services.LineInboundService.HandleWebhook(ctx.Request.Context(), channelID, signature, bodyBytes); err != nil {
ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
return
}

ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
71 changes: 71 additions & 0 deletions internal/handlers/third/threads_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package third

import (
"bytes"
"io"
"net/http"
"strings"

"agent-desk/internal/pkg/enums"
"agent-desk/internal/services"

"github.com/gin-gonic/gin"
)

// ThreadsGetWebhook handles Meta webhook verification (hub.challenge).
func ThreadsGetWebhook(ctx *gin.Context) {
mode := strings.TrimSpace(ctx.Query("hub.mode"))
token := strings.TrimSpace(ctx.Query("hub.verify_token"))
challenge := strings.TrimSpace(ctx.Query("hub.challenge"))

channelID := strings.TrimSpace(ctx.Param("channel_id"))
if channelID == "" {
channelID = strings.TrimSpace(ctx.Query("channel_id"))
}

if mode == "subscribe" {
if channelID != "" {
channel := services.ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeThreads, enums.StatusOk)
if channel != nil {
if cfg, err := services.ChannelService.ParseThreadsChannelConfig(channel.ConfigJSON); err == nil && cfg != nil {
if cfg.WebhookVerifyToken != "" && cfg.WebhookVerifyToken != token {
ctx.String(http.StatusForbidden, "Verification token mismatch")
return
}
}
}
}
Comment on lines +26 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

If channelID is empty, the verification token check is skipped entirely, and the challenge is returned with a 200 OK status. This allows anyone to verify a subscription without a valid token if they call the endpoint without a channel_id parameter.

To fix this security issue, you should fall back to the default Threads channel when channelID is empty (similar to how it is handled in ThreadsPostWebhook), and enforce token verification.

	if mode == "subscribe" {
		var channel *models.Channel
		if channelID != "" {
			channel = services.ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeThreads, enums.StatusOk)
		} else {
			channel = services.ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeThreads, enums.StatusOk)
		}

		if channel != nil {
			if cfg, err := services.ChannelService.ParseThreadsChannelConfig(channel.ConfigJSON); err == nil && cfg != nil {
				if cfg.WebhookVerifyToken != "" && cfg.WebhookVerifyToken != token {
					ctx.String(http.StatusForbidden, "Verification token mismatch")
					return
				}
			}
		} else {
			ctx.String(http.StatusNotFound, "Channel not found")
			return
		}

		ctx.String(http.StatusOK, challenge)
		return
	}


ctx.String(http.StatusOK, challenge)
return
}

ctx.String(http.StatusBadRequest, "Invalid verification request")
}

// ThreadsPostWebhook receives incoming webhook events from Meta Threads.
func ThreadsPostWebhook(ctx *gin.Context) {
channelID := strings.TrimSpace(ctx.Param("channel_id"))
if channelID == "" {
channelID = strings.TrimSpace(ctx.Query("channel_id"))
}

sigHeader := ctx.GetHeader("X-Hub-Signature-256")
if sigHeader == "" {
sigHeader = ctx.GetHeader("X-Hub-Signature")
}

bodyBytes, err := io.ReadAll(ctx.Request.Body)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "failed to read body"})
return
}
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))

if err := services.ThreadsInboundService.HandleWebhook(ctx.Request.Context(), channelID, sigHeader, bodyBytes); err != nil {
ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
return
}

ctx.JSON(http.StatusOK, gin.H{"ok": true, "message": "EVENT_RECEIVED"})
}
46 changes: 46 additions & 0 deletions internal/handlers/third/viber_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package third

import (
"bytes"
"io"
"net/http"
"strings"

"agent-desk/internal/services"

"github.com/gin-gonic/gin"
)

// ViberPostWebhook receives incoming callbacks from Viber.
//
// For a conversation_started callback with a welcome message configured,
// the welcome message JSON is written to the response body as required
// by the Viber API.
func ViberPostWebhook(ctx *gin.Context) {
channelID := strings.TrimSpace(ctx.Param("channel_id"))
if channelID == "" {
channelID = strings.TrimSpace(ctx.Query("channel_id"))
}

signature := ctx.GetHeader("X-Viber-Content-Signature")

bodyBytes, err := io.ReadAll(ctx.Request.Body)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "failed to read body"})
return
}
ctx.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))

responseBody, err := services.ViberInboundService.HandleWebhook(ctx.Request.Context(), channelID, signature, bodyBytes)
if err != nil {
ctx.JSON(http.StatusOK, gin.H{"ok": false, "error": err.Error()})
return
}

if responseBody != "" {
ctx.Data(http.StatusOK, "application/json", []byte(responseBody))
return
}

ctx.JSON(http.StatusOK, gin.H{"ok": true})
}
116 changes: 116 additions & 0 deletions internal/line/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package line

import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)

const defaultBaseURL = "https://api.line.me"

type Client struct {
channelAccessToken string
baseURL string
httpClient *http.Client
}

func NewClient(channelAccessToken string) *Client {
return &Client{
channelAccessToken: strings.TrimSpace(channelAccessToken),
baseURL: defaultBaseURL,
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}

func (c *Client) SetBaseURL(url string) {
if strings.TrimSpace(url) != "" {
c.baseURL = strings.TrimRight(strings.TrimSpace(url), "/")
}
}

// VerifyWebhookSignature validates the x-line-signature header value.
// The signature is HMAC-SHA256 of the raw body keyed by the channel secret,
// encoded as base64.
func VerifyWebhookSignature(channelSecret string, signature string, payload []byte) bool {
secret := strings.TrimSpace(channelSecret)
sig := strings.TrimSpace(signature)
if secret == "" || sig == "" {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}

// PushMessage sends a push message to a user via the LINE Messaging API.
func (c *Client) PushMessage(ctx context.Context, req PushMessageRequest) (*PushMessageResponse, error) {
if strings.TrimSpace(req.To) == "" {
return nil, fmt.Errorf("line recipient (to) is required")
}
if len(req.Messages) == 0 {
return nil, fmt.Errorf("line message list is required")
}

var resp PushMessageResponse
if err := c.doRequest(ctx, "/v2/bot/message/push", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}

func (c *Client) doRequest(ctx context.Context, path string, payload any, result any) error {
if c.channelAccessToken == "" {
return fmt.Errorf("line channel access token is required")
}

endpoint := c.baseURL + path

var bodyReader io.Reader
if payload != nil {
bodyBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal line request failed: %w", err)
}
bodyReader = bytes.NewBuffer(bodyBytes)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bodyReader)
if err != nil {
return fmt.Errorf("create line request failed: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.channelAccessToken)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}

res, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("line http request failed: %w", err)
}
defer res.Body.Close()

bodyBytes, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("read line response failed: %w", err)
}

if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("line api error (%d): %s", res.StatusCode, string(bodyBytes))
}

if result != nil && len(bodyBytes) > 0 {
if err := json.Unmarshal(bodyBytes, result); err != nil {
return fmt.Errorf("unmarshal line response failed: %w (body: %s)", err, string(bodyBytes))
}
}
return nil
}
67 changes: 67 additions & 0 deletions internal/line/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package line

import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)

func TestLinePushMessage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/bot/message/push" {
t.Errorf("expected path /v2/bot/message/push, got %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test_token" {
t.Errorf("expected Bearer test_token, got %s", r.Header.Get("Authorization"))
}
var req PushMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Errorf("decode request failed: %v", err)
}
if req.To != "U4af4980629" {
t.Errorf("expected to U4af4980629, got %s", req.To)
}
if len(req.Messages) != 1 || req.Messages[0].Text != "hello" {
t.Errorf("unexpected messages: %+v", req.Messages)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"sentMessages":[{"id":"4612309"}]}`))
}))
defer server.Close()

client := NewClient("test_token")
client.SetBaseURL(server.URL)

resp, err := client.PushMessage(context.Background(), PushMessageRequest{
To: "U4af4980629",
Messages: []MessageObject{{Type: "text", Text: "hello"}},
})
if err != nil {
t.Fatalf("PushMessage failed: %v", err)
}
if len(resp.SentMessages) != 1 || resp.SentMessages[0].ID != "4612309" {
t.Errorf("unexpected response: %+v", resp)
}
}

func TestLineVerifyWebhookSignature(t *testing.T) {
const secret = "8c570fa6dd201bb328f1c1eac23a96d8"
body := []byte(`{"destination":"U8e742f61d673b39c7fff3cecb7536ef0","events":[]}`)

mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
valid := base64.StdEncoding.EncodeToString(mac.Sum(nil))

if !VerifyWebhookSignature(secret, valid, body) {
t.Errorf("expected valid signature to verify")
}
if VerifyWebhookSignature(secret, "bad-signature", body) {
t.Errorf("expected invalid signature to fail")
}
}
Loading
Loading