611 lines
18 KiB
Go
611 lines
18 KiB
Go
package yunzhijia
|
||||
|
|
|
|||
|
|
import (
|
|||
|
|
"bytes"
|
|||
|
|
"context"
|
|||
|
|
"crypto/hmac"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"io"
|
|||
|
|
"mime"
|
|||
|
|
"net/http"
|
|||
|
|
"net/url"
|
|||
|
|
"path/filepath"
|
|||
|
|
"strings"
|
|||
|
|
"sync"
|
|||
|
|
"time"
|
|||
|
|
"unicode"
|
|||
|
|
"unicode/utf8"
|
|||
|
|
|
|||
|
|
"github.com/gin-gonic/gin"
|
|||
|
|
|
|||
|
|
"github.com/Tencent/WeKnora/internal/im"
|
|||
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// textMessageType is the Yunzhijia message type value for plain text messages.
|
|||
|
|
const textMessageType = 2
|
|||
|
|
|
|||
|
|
// markdownFormatType requests Yunzhijia to render Content as Markdown.
|
|||
|
|
const markdownFormatType = "markdown"
|
|||
|
|
|
|||
|
|
// Compile-time check.
|
|||
|
|
var _ im.Adapter = (*Adapter)(nil)
|
|||
|
|
var _ im.FileDownloader = (*Adapter)(nil)
|
|||
|
|
|
|||
|
|
var yunzhijiaAuthURL = "https://yunzhijia.com/api/oauth2_v12/auth/getAppAccessToken"
|
|||
|
|
var yunzhijiaDownloadFileBaseURL = "https://yunzhijia.com/gateway/docrest/doc/file/downloadfileOpen"
|
|||
|
|
|
|||
|
|
var validateDownloadFileURL = func(rawURL string) error {
|
|||
|
|
_, err := validateEndpointURL(rawURL, "https", "yunzhijia.com")
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// maxDownloadFileSize caps the size of a file downloaded from Yunzhijia and read
|
|||
|
|
// into memory, to avoid unbounded memory usage from a large/malicious response.
|
|||
|
|
const maxDownloadFileSize = 32 << 20 // 32 MiB
|
|||
|
|
|
|||
|
|
// maxDownloadRedirects limits how many redirects DownloadFile follows manually.
|
|||
|
|
// The shared httpClient disables automatic redirects (SSRF safety), but the
|
|||
|
|
// Yunzhijia download endpoint may 302 to a signed URL, so we follow a single hop
|
|||
|
|
// while re-validating the target host stays within the allowed suffix.
|
|||
|
|
const maxDownloadRedirects = 1
|
|||
|
|
|
|||
|
|
// Adapter implements im.Adapter for Yunzhijia (云之家).
|
|||
|
|
type Adapter struct {
|
|||
|
|
sendMsgURL string
|
|||
|
|
secret string
|
|||
|
|
appID string
|
|||
|
|
appSecret string
|
|||
|
|
httpClient *http.Client
|
|||
|
|
allowedWebhookHostSuffix string
|
|||
|
|
tokenMu sync.Mutex
|
|||
|
|
accessToken string
|
|||
|
|
accessTokenExpiresAt time.Time
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// NewAdapter creates a Yunzhijia adapter.
|
|||
|
|
func NewAdapter(sendMsgURL, secret, appID, appSecret string, timeoutSeconds int, allowedHostSuffix string) *Adapter {
|
|||
|
|
if timeoutSeconds <= 0 {
|
|||
|
|
timeoutSeconds = 10
|
|||
|
|
}
|
|||
|
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|||
|
|
transport.Proxy = nil
|
|||
|
|
transport.DialContext = safeDialContext
|
|||
|
|
return &Adapter{
|
|||
|
|
sendMsgURL: strings.TrimSpace(sendMsgURL),
|
|||
|
|
secret: strings.TrimSpace(secret),
|
|||
|
|
appID: strings.TrimSpace(appID),
|
|||
|
|
appSecret: strings.TrimSpace(appSecret),
|
|||
|
|
allowedWebhookHostSuffix: strings.TrimSpace(allowedHostSuffix),
|
|||
|
|
httpClient: &http.Client{
|
|||
|
|
Timeout: time.Duration(timeoutSeconds) * time.Second,
|
|||
|
|
Transport: transport,
|
|||
|
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
|||
|
|
return http.ErrUseLastResponse
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (a *Adapter) Platform() im.Platform {
|
|||
|
|
return im.PlatformYunzhijia
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (a *Adapter) HandleURLVerification(c *gin.Context) bool {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// VerifyCallback verifies the Yunzhijia callback signature (HmacSHA1).
|
|||
|
|
// If secret is not configured, verification is skipped.
|
|||
|
|
func (a *Adapter) VerifyCallback(c *gin.Context) error {
|
|||
|
|
if a.secret == "" {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bodyBytes, err := io.ReadAll(c.Request.Body)
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("read body: %w", err)
|
|||
|
|
}
|
|||
|
|
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
|||
|
|
|
|||
|
|
var msg callbackMessage
|
|||
|
|
if err := json.Unmarshal(bodyBytes, &msg); err != nil {
|
|||
|
|
return fmt.Errorf("parse callback for verification: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Read the sign header (case-insensitive via gin's GetHeader).
|
|||
|
|
sign := c.GetHeader("sign")
|
|||
|
|
if sign == "" {
|
|||
|
|
sign = c.GetHeader("Sign")
|
|||
|
|
}
|
|||
|
|
if sign == "" {
|
|||
|
|
sign = c.GetHeader("SIGN")
|
|||
|
|
}
|
|||
|
|
if sign == "" {
|
|||
|
|
return fmt.Errorf("missing sign header")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
expected := computeSignature(a.secret, &msg)
|
|||
|
|
if !hmac.Equal([]byte(sign), []byte(expected)) {
|
|||
|
|
return fmt.Errorf("invalid signature")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ParseCallback parses a Yunzhijia webhook callback into an IncomingMessage.
|
|||
|
|
// Returns nil for non-text messages or empty content.
|
|||
|
|
func (a *Adapter) ParseCallback(c *gin.Context) (*im.IncomingMessage, error) {
|
|||
|
|
bodyBytes, err := io.ReadAll(c.Request.Body)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("read body: %w", err)
|
|||
|
|
}
|
|||
|
|
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
|||
|
|
|
|||
|
|
var msg callbackMessage
|
|||
|
|
if err := json.Unmarshal(bodyBytes, &msg); err != nil {
|
|||
|
|
return nil, fmt.Errorf("parse callback: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return toIncomingMessage(c.Request.Context(), &msg), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func toIncomingMessage(ctx context.Context, msg *callbackMessage) *im.IncomingMessage {
|
|||
|
|
if msg.Type != textMessageType {
|
|||
|
|
logger.Infof(ctx,
|
|||
|
|
"[Yunzhijia] Skip non-text message: type=%d msgId=%s", msg.Type, msg.MsgID)
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
param, err := parseMessageParam(msg.MsgParam)
|
|||
|
|
if err != nil {
|
|||
|
|
logger.Warnf(ctx, "[Yunzhijia] Failed to parse msgParam: msgId=%s err=%v", msg.MsgID, err)
|
|||
|
|
}
|
|||
|
|
if param != nil {
|
|||
|
|
// Keep topic diagnostics to identifiers only: they are sufficient to
|
|||
|
|
// validate reply-root stability without recording user message content.
|
|||
|
|
logger.Infof(ctx, "[Yunzhijia] Thread callback: msg_id=%s reply_msg_id=%s reply_root_msg_id=%s",
|
|||
|
|
msg.MsgID, param.ReplyMsgID, param.ReplyRootMsgID)
|
|||
|
|
}
|
|||
|
|
image, hasImage := param.firstImage()
|
|||
|
|
|
|||
|
|
content := strings.TrimSpace(msg.Content)
|
|||
|
|
if content == "" && !hasImage {
|
|||
|
|
logger.Infof(ctx,
|
|||
|
|
"[Yunzhijia] Skip empty content: msgId=%s", msg.MsgID)
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Conversation bots should only receive messages explicitly addressed to them.
|
|||
|
|
var mentioned bool
|
|||
|
|
content, mentioned = cleanAtMention(content, msg.RobotName)
|
|||
|
|
if !mentioned {
|
|||
|
|
mentioned = messageParamMentionsRobot(param, msg.RobotID)
|
|||
|
|
}
|
|||
|
|
if !mentioned {
|
|||
|
|
logger.Infof(ctx, "[Yunzhijia] Skip message without robot mention: msgId=%s", msg.MsgID)
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if content == "" && !hasImage {
|
|||
|
|
logger.Infof(ctx,
|
|||
|
|
"[Yunzhijia] Skip after cleaning @mention: msgId=%s", msg.MsgID)
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
userID := firstNonEmpty(msg.OperatorOpenid, msg.OperatorOID, msg.OpenID, msg.SenderID, msg.OperatorID, msg.OperatorUserID)
|
|||
|
|
userName := firstNonEmpty(msg.OperatorName, msg.SenderName)
|
|||
|
|
chatType := im.ChatTypeGroup
|
|||
|
|
chatID := firstNonEmpty(msg.GroupID, msg.RobotID)
|
|||
|
|
|
|||
|
|
incoming := &im.IncomingMessage{
|
|||
|
|
Platform: im.PlatformYunzhijia,
|
|||
|
|
MessageType: im.MessageTypeText,
|
|||
|
|
UserID: userID,
|
|||
|
|
UserName: userName,
|
|||
|
|
ChatID: chatID,
|
|||
|
|
ChatType: chatType,
|
|||
|
|
Content: content,
|
|||
|
|
MessageID: msg.MsgID,
|
|||
|
|
ThreadID: threadIDForMessage(msg.MsgID, param),
|
|||
|
|
Extra: map[string]string{
|
|||
|
|
"robot_id": msg.RobotID,
|
|||
|
|
"robot_name": msg.RobotName,
|
|||
|
|
"group_id": msg.GroupID,
|
|||
|
|
"group_type": fmt.Sprintf("%d", msg.GroupType),
|
|||
|
|
"operator_name": userName,
|
|||
|
|
"time": fmt.Sprintf("%d", msg.Time),
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
if hasImage {
|
|||
|
|
incoming.MessageType = im.MessageTypeImage
|
|||
|
|
incoming.FileKey = image.Data
|
|||
|
|
incoming.FileName = defaultImageFileName(msg.MsgID)
|
|||
|
|
incoming.Extra["yunzhijia_image_width"] = fmt.Sprintf("%d", image.Width)
|
|||
|
|
incoming.Extra["yunzhijia_image_height"] = fmt.Sprintf("%d", image.Height)
|
|||
|
|
}
|
|||
|
|
return incoming
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// threadIDForMessage returns the message-thread identifier received from
|
|||
|
|
// Yunzhijia. A top-level message starts a new thread with its own msgId;
|
|||
|
|
// replies carry replyRootMsgId (normally the bot message at the thread root).
|
|||
|
|
func threadIDForMessage(messageID string, param *messageParam) string {
|
|||
|
|
if param != nil && param.ReplyRootMsgID != "" {
|
|||
|
|
return param.ReplyRootMsgID
|
|||
|
|
}
|
|||
|
|
return messageID
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func firstNonEmpty(values ...string) string {
|
|||
|
|
for _, value := range values {
|
|||
|
|
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|||
|
|
return trimmed
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func messageParamMentionsRobot(param *messageParam, robotID string) bool {
|
|||
|
|
if param == nil || robotID == "" {
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
for _, notifyTo := range param.NotifyTo {
|
|||
|
|
if notifyTo == robotID {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for _, desc := range param.Desc {
|
|||
|
|
if desc.Type == "at" && desc.Data == robotID {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func defaultImageFileName(msgID string) string {
|
|||
|
|
if msgID == "" {
|
|||
|
|
return "yunzhijia-image.png"
|
|||
|
|
}
|
|||
|
|
return msgID + ".png"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// cleanAtMention removes @RobotName from the beginning of user content.
|
|||
|
|
func cleanAtMention(content, robotName string) (string, bool) {
|
|||
|
|
if robotName == "" {
|
|||
|
|
return content, false
|
|||
|
|
}
|
|||
|
|
prefix := "@" + robotName
|
|||
|
|
trimmed := strings.TrimLeft(content, " \t")
|
|||
|
|
if !strings.HasPrefix(trimmed, prefix) {
|
|||
|
|
return content, false
|
|||
|
|
}
|
|||
|
|
rest := trimmed[len(prefix):]
|
|||
|
|
if rest == "" {
|
|||
|
|
return "", true
|
|||
|
|
}
|
|||
|
|
separator, _ := utf8.DecodeRuneInString(rest)
|
|||
|
|
if !unicode.IsSpace(separator) && !strings.ContainsRune("::,,", separator) {
|
|||
|
|
return content, false
|
|||
|
|
}
|
|||
|
|
return strings.TrimLeftFunc(rest, func(r rune) bool {
|
|||
|
|
return unicode.IsSpace(r) || strings.ContainsRune("::,,", r)
|
|||
|
|
}), true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// SendReply sends a reply to Yunzhijia via the configured sendMsgUrl.
|
|||
|
|
func (a *Adapter) SendReply(ctx context.Context, incoming *im.IncomingMessage, reply *im.ReplyMessage) error {
|
|||
|
|
if a.sendMsgURL == "" {
|
|||
|
|
return fmt.Errorf("yunzhijia send_msg_url is not configured")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Validate the send URL to prevent SSRF.
|
|||
|
|
if err := a.validateSendURL(); err != nil {
|
|||
|
|
return err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
payload := sendMessagePayload{
|
|||
|
|
MsgType: textMessageType,
|
|||
|
|
Content: reply.Content,
|
|||
|
|
// WeKnora replies are authored in Markdown by default (see im.ReplyMessage.Content),
|
|||
|
|
// so request Markdown rendering from Yunzhijia unless explicitly overridden via
|
|||
|
|
// reply.Extra["yunzhijia_format_type"] (empty string disables the param entirely).
|
|||
|
|
Param: &sendMessageParam{FormatType: markdownFormatType},
|
|||
|
|
}
|
|||
|
|
if reply.Extra != nil {
|
|||
|
|
if formatType, ok := reply.Extra["yunzhijia_format_type"]; ok {
|
|||
|
|
payload.Param.FormatType = formatType
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if incoming.MessageID != "" {
|
|||
|
|
payload.ParamType = 3
|
|||
|
|
payload.Param.ReplyMsgID = incoming.MessageID
|
|||
|
|
payload.Param.IsReference = true
|
|||
|
|
payload.Param.ReplySummary = incoming.Content
|
|||
|
|
payload.Param.ReplyPersonName = incoming.UserName
|
|||
|
|
} else if payload.Param.FormatType == "" {
|
|||
|
|
// Keep the prior opt-out behaviour for non-reference replies.
|
|||
|
|
payload.Param = nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// When groupType == 3, don't set notifyParams (per reference implementation).
|
|||
|
|
groupType := ""
|
|||
|
|
if incoming.Extra != nil {
|
|||
|
|
groupType = incoming.Extra["group_type"]
|
|||
|
|
}
|
|||
|
|
if groupType != "3" && incoming.UserID != "" {
|
|||
|
|
payload.NotifyParams = []notifyParam{
|
|||
|
|
{
|
|||
|
|
Type: "openIds",
|
|||
|
|
Values: []string{incoming.UserID},
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
jsonBody, err := json.Marshal(payload)
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("marshal reply: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.sendMsgURL, bytes.NewReader(jsonBody))
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("create request: %w", err)
|
|||
|
|
}
|
|||
|
|
req.Header.Set("Content-Type", "application/json")
|
|||
|
|
|
|||
|
|
resp, err := a.httpClient.Do(req)
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("send reply: %w", err)
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
|
|||
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|||
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|||
|
|
return fmt.Errorf("yunzhijia sendMsgUrl returned %d: %s", resp.StatusCode, string(respBody))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// DownloadFile downloads a file/image resource sent by Yunzhijia.
|
|||
|
|
func (a *Adapter) DownloadFile(ctx context.Context, msg *im.IncomingMessage) (io.ReadCloser, string, error) {
|
|||
|
|
if msg.FileKey == "" {
|
|||
|
|
return nil, "", fmt.Errorf("yunzhijia file id is required")
|
|||
|
|
}
|
|||
|
|
fileID := strings.TrimSpace(msg.FileKey)
|
|||
|
|
if err := validateFileID(fileID); err != nil {
|
|||
|
|
return nil, "", fmt.Errorf("invalid yunzhijia file id: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
accessToken, err := a.getAppAccessToken(ctx)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, "", err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
downloadURL := buildDownloadFileURL(fileID)
|
|||
|
|
if err := validateDownloadFileURL(downloadURL); err != nil {
|
|||
|
|
return nil, "", fmt.Errorf("download url rejected: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resp, err := a.fetchDownload(ctx, downloadURL, accessToken)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, "", err
|
|||
|
|
}
|
|||
|
|
if resp.StatusCode != http.StatusOK {
|
|||
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|||
|
|
resp.Body.Close()
|
|||
|
|
return nil, "", fmt.Errorf("download file returned %d: %s", resp.StatusCode, string(body))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fileName := msg.FileName
|
|||
|
|
if fileName == "" {
|
|||
|
|
fileName = fileID
|
|||
|
|
}
|
|||
|
|
fileName = resolveDownloadFileName(fileName, resp)
|
|||
|
|
return newLimitedReadCloser(resp.Body, maxDownloadFileSize), fileName, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// fetchDownload performs the download request, following at most maxDownloadRedirects
|
|||
|
|
// redirects. Each redirect target is re-validated against the allowed host suffix so
|
|||
|
|
// the request cannot be redirected off the trusted domain. The bearer token is only
|
|||
|
|
// sent on the initial request and never forwarded across a redirect.
|
|||
|
|
func (a *Adapter) fetchDownload(ctx context.Context, downloadURL, accessToken string) (*http.Response, error) {
|
|||
|
|
currentURL := downloadURL
|
|||
|
|
for redirects := 0; ; redirects++ {
|
|||
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, currentURL, nil)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("create download request: %w", err)
|
|||
|
|
}
|
|||
|
|
if redirects == 0 {
|
|||
|
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
|||
|
|
}
|
|||
|
|
resp, err := a.httpClient.Do(req)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("download file: %w", err)
|
|||
|
|
}
|
|||
|
|
if resp.StatusCode < http.StatusMultipleChoices || resp.StatusCode >= http.StatusBadRequest {
|
|||
|
|
return resp, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Redirect (3xx): re-validate the target before following.
|
|||
|
|
location := strings.TrimSpace(resp.Header.Get("Location"))
|
|||
|
|
resp.Body.Close()
|
|||
|
|
if redirects >= maxDownloadRedirects {
|
|||
|
|
return nil, fmt.Errorf("download file: too many redirects")
|
|||
|
|
}
|
|||
|
|
if location == "" {
|
|||
|
|
return nil, fmt.Errorf("download file: redirect without Location")
|
|||
|
|
}
|
|||
|
|
resolved, err := resolveRedirectURL(currentURL, location)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, fmt.Errorf("download file: invalid redirect location: %w", err)
|
|||
|
|
}
|
|||
|
|
if err := validateDownloadFileURL(resolved); err != nil {
|
|||
|
|
return nil, fmt.Errorf("download redirect rejected: %w", err)
|
|||
|
|
}
|
|||
|
|
currentURL = resolved
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func resolveRedirectURL(base, location string) (string, error) {
|
|||
|
|
baseURL, err := url.Parse(base)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
locURL, err := url.Parse(location)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
return baseURL.ResolveReference(locURL).String(), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// newLimitedReadCloser wraps rc so that reading more than limit bytes returns an
|
|||
|
|
// error instead of silently truncating, while Close still closes the underlying body.
|
|||
|
|
func newLimitedReadCloser(rc io.ReadCloser, limit int64) io.ReadCloser {
|
|||
|
|
return &limitedReadCloser{r: io.LimitReader(rc, limit+1), body: rc, limit: limit}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type limitedReadCloser struct {
|
|||
|
|
r io.Reader
|
|||
|
|
body io.Closer
|
|||
|
|
limit int64
|
|||
|
|
read int64
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (l *limitedReadCloser) Read(p []byte) (int, error) {
|
|||
|
|
n, err := l.r.Read(p)
|
|||
|
|
l.read += int64(n)
|
|||
|
|
if l.read > l.limit {
|
|||
|
|
return n, fmt.Errorf("yunzhijia file exceeds max download size of %d bytes", l.limit)
|
|||
|
|
}
|
|||
|
|
return n, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (l *limitedReadCloser) Close() error {
|
|||
|
|
return l.body.Close()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func buildDownloadFileURL(fileID string) string {
|
|||
|
|
u, _ := url.Parse(yunzhijiaDownloadFileBaseURL)
|
|||
|
|
query := url.Values{}
|
|||
|
|
query.Set("fileId", fileID)
|
|||
|
|
u.RawQuery = query.Encode()
|
|||
|
|
return u.String()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func validateFileID(fileID string) error {
|
|||
|
|
if fileID == "" {
|
|||
|
|
return fmt.Errorf("empty")
|
|||
|
|
}
|
|||
|
|
if len(fileID) > 256 {
|
|||
|
|
return fmt.Errorf("too long")
|
|||
|
|
}
|
|||
|
|
if strings.ContainsAny(fileID, `/\?#&`) {
|
|||
|
|
return fmt.Errorf("contains path or query separators")
|
|||
|
|
}
|
|||
|
|
for _, r := range fileID {
|
|||
|
|
if unicode.IsControl(r) || unicode.IsSpace(r) {
|
|||
|
|
return fmt.Errorf("contains whitespace or control character")
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (a *Adapter) getAppAccessToken(ctx context.Context) (string, error) {
|
|||
|
|
if a.appID == "" || a.appSecret == "" {
|
|||
|
|
return "", fmt.Errorf("yunzhijia app_id and app_secret are required to download files")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
a.tokenMu.Lock()
|
|||
|
|
if a.accessToken != "" && time.Now().Before(a.accessTokenExpiresAt) {
|
|||
|
|
token := a.accessToken
|
|||
|
|
a.tokenMu.Unlock()
|
|||
|
|
return token, nil
|
|||
|
|
}
|
|||
|
|
a.tokenMu.Unlock()
|
|||
|
|
|
|||
|
|
payload := map[string]any{
|
|||
|
|
"appId": a.appID,
|
|||
|
|
"secret": a.appSecret,
|
|||
|
|
"timestamp": time.Now().UnixMilli(),
|
|||
|
|
}
|
|||
|
|
body, err := json.Marshal(payload)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("marshal yunzhijia token request: %w", err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, yunzhijiaAuthURL, bytes.NewReader(body))
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("create yunzhijia token request: %w", err)
|
|||
|
|
}
|
|||
|
|
req.Header.Set("Content-Type", "application/json")
|
|||
|
|
|
|||
|
|
resp, err := a.httpClient.Do(req)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("request yunzhijia app access token: %w", err)
|
|||
|
|
}
|
|||
|
|
defer resp.Body.Close()
|
|||
|
|
respBody, err := io.ReadAll(resp.Body)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", fmt.Errorf("read yunzhijia token response: %w", err)
|
|||
|
|
}
|
|||
|
|
if resp.StatusCode != http.StatusOK {
|
|||
|
|
return "", fmt.Errorf("yunzhijia token endpoint returned %d: %s", resp.StatusCode, string(respBody))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var tokenResp appAccessTokenResponse
|
|||
|
|
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
|||
|
|
return "", fmt.Errorf("parse yunzhijia token response: %w", err)
|
|||
|
|
}
|
|||
|
|
if !tokenResp.Success || tokenResp.ErrorCode != 0 || tokenResp.Data.AccessToken == "" {
|
|||
|
|
return "", fmt.Errorf("yunzhijia token response failed: errorCode=%d error=%v", tokenResp.ErrorCode, tokenResp.Error)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
expiresIn := tokenResp.Data.ExpireIn
|
|||
|
|
if expiresIn <= 0 {
|
|||
|
|
expiresIn = 3600
|
|||
|
|
}
|
|||
|
|
expiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
|||
|
|
if expiresIn > 120 {
|
|||
|
|
expiresAt = expiresAt.Add(-60 * time.Second)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
a.tokenMu.Lock()
|
|||
|
|
a.accessToken = tokenResp.Data.AccessToken
|
|||
|
|
a.accessTokenExpiresAt = expiresAt
|
|||
|
|
a.tokenMu.Unlock()
|
|||
|
|
return tokenResp.Data.AccessToken, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func resolveDownloadFileName(fallback string, resp *http.Response) string {
|
|||
|
|
fileName := fallback
|
|||
|
|
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
|
|||
|
|
if _, params, err := mime.ParseMediaType(cd); err == nil {
|
|||
|
|
if name := strings.TrimSpace(params["filename"]); name != "" {
|
|||
|
|
fileName = name
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if filepath.Ext(fileName) == "" {
|
|||
|
|
switch strings.ToLower(strings.TrimSpace(strings.Split(resp.Header.Get("Content-Type"), ";")[0])) {
|
|||
|
|
case "image/jpeg":
|
|||
|
|
fileName += ".jpg"
|
|||
|
|
case "image/png":
|
|||
|
|
fileName += ".png"
|
|||
|
|
case "image/gif":
|
|||
|
|
fileName += ".gif"
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return fileName
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// validateSendURL checks that sendMsgUrl is safe to call (HTTPS, no internal IPs, allowed host).
|
|||
|
|
func (a *Adapter) validateSendURL() error {
|
|||
|
|
_, err := validateEndpointURL(a.sendMsgURL, "https", a.allowedWebhookHostSuffix)
|
|||
|
|
if err != nil {
|
|||
|
|
return fmt.Errorf("invalid send_msg_url: %w", err)
|
|||
|
|
}
|
|||
|
|
return nil
|
|||
|
|
}
|