Files
dingtalk-workspace-cli/internal/shortcut/chat/resource_download.go
T

617 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package chat
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
pathpkg "path"
"path/filepath"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const resourceDownloadTimeout = 10 * time.Minute
var (
resourceGetwd = os.Getwd
resourceAbs = filepath.Abs
resourceEvalSymlinks = filepath.EvalSymlinks
resourceStat = os.Stat
resourceLstat = os.Lstat
resourceRel = filepath.Rel
resourceMkdir = os.Mkdir
resourceCreateTemp = os.CreateTemp
resourceCopy = io.Copy
resourceTempSync = (*os.File).Sync
resourceTempClose = (*os.File).Close
resourceRename = replaceFileAtomically
resourceLink = os.Link
resourceDownload = downloadResourceAtomically
)
// MessagesResourceDownload resolves a temporary IM resource URL and saves the
// bytes through a safe, atomic, no-clobber local-file workflow.
var MessagesResourceDownload = shortcut.Shortcut{
Service: "chat",
Command: "+messages-resource-download",
Product: "im",
Description: "安全下载消息资源(图片/视频/语音/文件)到本地",
Intent: "当你需要拿到消息里的实际图片、视频、语音或钉盘文件,而不只是资源 ID 时使用;" +
"mediaId 用消息和会话身份换取下载地址,fileId 复用钉盘下载能力,再安全写入工作目录内的相对路径。" +
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;下载采用整文件临时落盘后原子发布,不支持 Range 断点续传。按既有安全本地下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文", Enum: []string{"mediaId", "fileId"}},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "消息中的 mediaId 或 fileId", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "mediaId 所属消息的 openMessageId;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文"},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "mediaId 所属会话的 openConversationId;--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文"},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对路径;不允许绝对路径或 .. 逃逸"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖工作目录内已存在的目标文件(默认拒绝)"},
},
Constraints: []shortcut.Constraint{
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"output"},
Description: "--output 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"type", "message-id", "open-conversation-id"},
Description: "--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文",
},
},
Tips: []string{
`dws chat +messages-resource-download --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId>`,
`dws chat +messages-resource-download --type fileId --resource-id <fileId> --output ./downloads/`,
`dws chat +messages-resource-download --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if err := validateResourceDownloadOutput(rt.Str("output")); err != nil {
return err
}
resourceType, _ := canonicalMessageResourceType(rt.Str("type"))
if resourceType == "mediaId" &&
(strings.TrimSpace(rt.Str("message-id")) == "" ||
strings.TrimSpace(rt.Str("open-conversation-id")) == "") {
return apperrors.NewValidation(
"--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
resourceType, _ := canonicalMessageResourceType(rt.Str("type"))
plan := map[string]any{
"resourceType": resourceType,
"resourceId": rt.Str("resource-id"),
"messageId": rt.Str("message-id"),
"openConversationId": rt.Str("open-conversation-id"),
"output": rt.Str("output"),
"overwrite": rt.Bool("overwrite"),
}
if rt.DryRun() {
plan["dryRun"] = true
plan["steps"] = []string{
"resolve temporary download URL",
"validate output path and no-clobber policy",
"download to a temporary file",
"atomically publish the completed file",
}
return rt.Output(plan)
}
data, err := resolveMessageResourceDownloadData(
rt,
resourceType,
rt.Str("resource-id"),
rt.Str("message-id"),
rt.Str("open-conversation-id"),
)
if err != nil {
return err
}
resourceURL, headers, err := resourceDownloadInfo(data)
if err != nil {
return err
}
cwd, err := resourceGetwd()
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
destPath, relativePath, err := resolveResourceDownloadPath(
cwd,
rt.Str("output"),
resourceURL,
rt.Bool("overwrite"),
resourceDownloadPreferredName(data),
)
if err != nil {
return err
}
size, err := resourceDownload(
rt.Command().Context(), nil, resourceURL, headers, destPath, rt.Bool("overwrite"))
if err != nil {
return err
}
return rt.Output(map[string]any{
"messageId": rt.Str("message-id"),
"resourceId": rt.Str("resource-id"),
"resourceType": resourceType,
"localPath": filepath.ToSlash(relativePath),
"sizeBytes": size,
})
},
}
func resolveMessageResourceDownloadData(
rt *shortcut.RuntimeContext,
resourceType, resourceID, messageID, conversationID string,
) (map[string]any, error) {
resourceType, ok := canonicalMessageResourceType(resourceType)
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf(
"不支持的消息资源类型 %q;仅支持 mediaId 或 fileId", resourceType))
}
if resourceType == "fileId" {
return rt.CallMCPData("drive", "download_file", map[string]any{
"fileId": resourceID,
})
}
return rt.CallMCPData("im", "get_resource_download_url", map[string]any{
"resourceType": "mediaId",
"resourceId": resourceID,
"openMessageId": messageID,
"openConversationId": conversationID,
})
}
func canonicalMessageResourceType(resourceType string) (string, bool) {
switch {
case strings.EqualFold(strings.TrimSpace(resourceType), "mediaId"):
return "mediaId", true
case strings.EqualFold(strings.TrimSpace(resourceType), "fileId"):
return "fileId", true
default:
return strings.TrimSpace(resourceType), false
}
}
func validateResourceDownloadOutput(output string) error {
return validateResourceDownloadOutputFlag(output, "--output")
}
func validateResourceDownloadOutputFlag(output, flagName string) error {
output = strings.TrimSpace(output)
if output == "" {
return apperrors.NewValidation(flagName + " 不能为空")
}
if resourcePathIsAbsolute(output) {
return apperrors.NewValidation(flagName + " 只接受工作目录内的相对路径")
}
if resourcePathEscapesBase(output) {
return apperrors.NewValidation(flagName + " 不允许使用 .. 逃逸工作目录")
}
return nil
}
func resourcePathIsAbsolute(value string) bool {
if filepath.IsAbs(value) {
return true
}
portable := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
if pathpkg.IsAbs(portable) {
return true
}
return len(portable) >= 2 &&
((portable[0] >= 'a' && portable[0] <= 'z') ||
(portable[0] >= 'A' && portable[0] <= 'Z')) &&
portable[1] == ':'
}
// resourcePathEscapesBase reports whether a relative path escapes its base.
// Normalize both separators so the check remains portable on every host OS.
func resourcePathEscapesBase(value string) bool {
portable := strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
clean := pathpkg.Clean(portable)
return clean == ".." || strings.HasPrefix(clean, "../")
}
func resourceDownloadInfo(data map[string]any) (string, map[string]string, error) {
for {
result, ok := data["result"].(map[string]any)
if !ok {
break
}
data = result
}
resourceURL := ""
switch value := data["resourceUrl"].(type) {
case string:
resourceURL = strings.TrimSpace(value)
case []any:
for _, item := range value {
if candidate, ok := item.(string); ok && strings.TrimSpace(candidate) != "" {
resourceURL = strings.TrimSpace(candidate)
break
}
}
}
if resourceURL == "" {
if value, ok := data["downloadUrl"].(string); ok {
resourceURL = strings.TrimSpace(value)
}
}
parsed, err := url.Parse(resourceURL)
if err != nil || strings.TrimSpace(parsed.Host) == "" {
return "", nil, apperrors.NewAPI(
"资源下载接口未返回合法的 HTTPS 下载地址")
}
if parsed.Scheme == "http" && isAliyunOSSHost(parsed.Hostname()) {
// The IM backend currently returns a signed plaintext OSS URL even
// though the same object endpoint supports TLS. Upgrade only the
// official OSS hostname; never rewrite arbitrary HTTP origins.
parsed.Scheme = "https"
resourceURL = parsed.String()
}
parsed, err = validateResourceDownloadURL(resourceURL)
if err != nil {
return "", nil, apperrors.NewAPI(fmt.Sprintf(
"资源下载接口返回了不受信任的下载地址: %v", err))
}
resourceURL = parsed.String()
headers := map[string]string{}
if values, ok := data["headers"].(map[string]any); ok {
for key, value := range values {
if text, ok := value.(string); ok && strings.TrimSpace(key) != "" {
headers[key] = text
}
}
}
return resourceURL, headers, nil
}
func resourceDownloadPreferredName(data map[string]any) string {
for {
result, ok := data["result"].(map[string]any)
if !ok {
break
}
data = result
}
name, _ := data["fileName"].(string)
return strings.TrimSpace(name)
}
func isAliyunOSSHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
if !strings.HasSuffix(host, ".aliyuncs.com") {
return false
}
for _, label := range strings.Split(strings.TrimSuffix(host, ".aliyuncs.com"), ".") {
if (label == "oss" || strings.HasPrefix(label, "oss-")) &&
!strings.Contains(label, "internal") {
return true
}
}
return false
}
func validateResourceDownloadURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil ||
parsed.Scheme != "https" ||
strings.TrimSpace(parsed.Host) == "" ||
parsed.User != nil {
return nil, apperrors.NewValidation("资源下载地址必须是受信任域名上的 HTTPS URL")
}
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), "."))
if host == "" || net.ParseIP(host) != nil || !isResourceDownloadAllowedHost(host) {
return nil, apperrors.NewValidation(fmt.Sprintf(
"资源下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host))
}
if port := parsed.Port(); port != "" && port != "443" {
return nil, apperrors.NewValidation("资源下载地址只允许使用 HTTPS 默认端口")
}
return parsed, nil
}
func isResourceDownloadAllowedHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
// Lower tools may return signed headers with the URL. Keep those headers
// confined to platform-owned public download families; extend this list
// only after observing another official production download host.
return isAliyunOSSHost(host) ||
host == "dingtalk.com" ||
strings.HasSuffix(host, ".dingtalk.com")
}
func resolveResourceDownloadPath(
baseDir, output, resourceURL string,
overwrite bool,
preferredName ...string,
) (absolutePath, relativePath string, err error) {
if err := validateResourceDownloadOutput(output); err != nil {
return "", "", err
}
baseDir, err = resourceAbs(baseDir)
if err != nil {
return "", "", apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
realBase, err := resourceEvalSymlinks(baseDir)
if err != nil {
return "", "", apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
rawOutput := strings.TrimSpace(output)
directoryIntent := strings.HasSuffix(rawOutput, string(os.PathSeparator)) ||
strings.HasSuffix(rawOutput, "/")
output = filepath.Clean(rawOutput)
candidate := filepath.Join(realBase, output)
info, statErr := resourceStat(candidate)
isDirectory := (statErr == nil && info.IsDir()) ||
directoryIntent ||
output == "."
if isDirectory {
candidate = filepath.Join(candidate, resourceDownloadFilename(resourceURL, preferredName...))
}
parent := filepath.Dir(candidate)
if err := ensureResourceDownloadParent(realBase, parent); err != nil {
return "", "", err
}
realParent, err := resourceEvalSymlinks(parent)
if err != nil {
return "", "", apperrors.NewInternal(fmt.Sprintf("解析输出目录失败: %v", err))
}
parentRel, err := resourceRel(realBase, realParent)
if err != nil || resourcePathEscapesBase(parentRel) {
return "", "", apperrors.NewValidation("--output 解析后逃逸工作目录")
}
absolutePath = filepath.Join(realParent, filepath.Base(candidate))
if info, statErr := resourceLstat(absolutePath); statErr == nil {
if info.Mode()&os.ModeSymlink != 0 {
return "", "", apperrors.NewValidation("--output 目标不能是符号链接")
}
if info.IsDir() {
return "", "", apperrors.NewValidation("--output 目标是目录,无法写入文件")
}
if !overwrite {
return "", "", apperrors.NewValidation(
"目标文件已存在;如确认覆盖请显式传 --overwrite")
}
} else if !errors.Is(statErr, os.ErrNotExist) {
return "", "", apperrors.NewInternal(fmt.Sprintf("检查输出文件失败: %v", statErr))
}
relativePath, err = resourceRel(realBase, absolutePath)
if err != nil {
return "", "", apperrors.NewInternal(fmt.Sprintf("解析输出相对路径失败: %v", err))
}
return absolutePath, relativePath, nil
}
func ensureResourceDownloadParent(baseDir, parent string) error {
relative, err := resourceRel(baseDir, parent)
if err != nil || resourcePathEscapesBase(relative) {
return apperrors.NewValidation("--output 解析后逃逸工作目录")
}
if relative == "." {
return nil
}
current := baseDir
for _, part := range strings.Split(relative, string(os.PathSeparator)) {
current = filepath.Join(current, part)
info, statErr := resourceLstat(current)
if errors.Is(statErr, os.ErrNotExist) {
if mkdirErr := resourceMkdir(current, 0o755); mkdirErr != nil {
// A concurrent creator may have won the race. Re-check the
// resulting entry instead of following it implicitly.
info, statErr = resourceLstat(current)
if statErr != nil {
return apperrors.NewInternal(fmt.Sprintf(
"创建输出目录失败: %v", mkdirErr))
}
} else {
continue
}
} else if statErr != nil {
return apperrors.NewInternal(fmt.Sprintf(
"检查输出目录失败: %v", statErr))
}
if info.Mode()&os.ModeSymlink != 0 {
return apperrors.NewValidation("--output 父目录不能是符号链接")
}
if !info.IsDir() {
return apperrors.NewValidation("--output 父路径不是目录")
}
}
return nil
}
func resourceDownloadFilename(resourceURL string, preferredName ...string) string {
if len(preferredName) > 0 {
if name := safeResourceDownloadFilename(preferredName[0]); name != "" {
return name
}
}
parsed, err := url.Parse(resourceURL)
if err == nil {
name, unescapeErr := url.PathUnescape(filepath.Base(parsed.Path))
if unescapeErr == nil {
if name = safeResourceDownloadFilename(name); name != "" {
return name
}
}
}
return "download"
}
// safeResourceDownloadFilename returns a portable basename or an empty string
// for names that are unsafe or unusable on a supported platform. Server-provided
// file names are untrusted input, and downloads may be prepared on one OS then
// consumed on another.
func safeResourceDownloadFilename(raw string) string {
normalized := strings.ReplaceAll(raw, "\\", "/")
if strings.TrimSpace(normalized) != normalized {
return ""
}
name := filepath.Base(normalized)
if name == "" || name == "." || name == ".." ||
strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return ""
}
for _, char := range name {
if char < 0x20 || char == 0x7f || strings.ContainsRune(`<>:"/\|?*`, char) {
return ""
}
}
stem := name
if index := strings.IndexByte(stem, '.'); index >= 0 {
stem = stem[:index]
}
stem = strings.ToUpper(strings.TrimRight(stem, " ."))
switch stem {
case "CON", "PRN", "AUX", "NUL":
return ""
}
if len(stem) == 4 &&
(stem[:3] == "COM" || stem[:3] == "LPT") &&
stem[3] >= '1' && stem[3] <= '9' {
return ""
}
return name
}
func downloadResourceAtomically(
ctx context.Context,
client *http.Client,
resourceURL string,
headers map[string]string,
destPath string,
overwrite bool,
) (size int64, err error) {
if client == nil {
client = &http.Client{Timeout: resourceDownloadTimeout}
}
parsedResourceURL, err := validateResourceDownloadURL(resourceURL)
if err != nil {
return 0, err
}
clientCopy := *client
client = &clientCopy
originalRedirect := client.CheckRedirect
initialHost := strings.ToLower(parsedResourceURL.Hostname())
headersConfinedToInitialHost := true
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if _, redirectErr := validateResourceDownloadURL(req.URL.String()); redirectErr != nil {
return apperrors.NewValidation(fmt.Sprintf(
"资源下载重定向指向了不受信任的地址: %v", redirectErr))
}
if len(via) >= 10 {
return apperrors.NewAPI("资源下载重定向次数过多")
}
if !strings.EqualFold(req.URL.Hostname(), initialHost) {
headersConfinedToInitialHost = false
}
// net/http rebuilds redirect headers from the initial request on every
// hop. Once a chain leaves the original host, strip lower-service
// headers on every later hop so a same-host redirect on the new origin
// cannot silently restore them.
if !headersConfinedToInitialHost {
for key := range headers {
req.Header.Del(key)
}
}
if originalRedirect != nil {
return originalRedirect(req, via)
}
return nil
}
request, err := http.NewRequestWithContext(
ctx, http.MethodGet, parsedResourceURL.String(), nil)
if err != nil {
return 0, apperrors.NewValidation(fmt.Sprintf("创建资源下载请求失败: %v", err))
}
for key, value := range headers {
request.Header.Set(key, value)
}
response, err := client.Do(request)
if err != nil {
return 0, apperrors.NewAPI(fmt.Sprintf("下载消息资源失败: %v", err))
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return 0, apperrors.NewAPI(fmt.Sprintf(
"下载消息资源失败: HTTP %d", response.StatusCode))
}
parent := filepath.Dir(destPath)
temp, err := resourceCreateTemp(parent, "."+filepath.Base(destPath)+".part-*")
if err != nil {
return 0, apperrors.NewInternal(fmt.Sprintf("创建下载临时文件失败: %v", err))
}
tempPath := temp.Name()
defer func() {
_ = resourceTempClose(temp)
_ = os.Remove(tempPath)
}()
size, err = resourceCopy(temp, response.Body)
if err != nil {
return 0, apperrors.NewAPI(fmt.Sprintf("写入消息资源失败: %v", err))
}
if response.ContentLength >= 0 && size != response.ContentLength {
return 0, apperrors.NewAPI(fmt.Sprintf(
"消息资源大小校验失败: 下载 %d 字节,期望 %d 字节",
size, response.ContentLength))
}
if err := resourceTempSync(temp); err != nil {
return 0, apperrors.NewInternal(fmt.Sprintf("同步消息资源失败: %v", err))
}
if err := resourceTempClose(temp); err != nil {
return 0, apperrors.NewInternal(fmt.Sprintf("关闭消息资源失败: %v", err))
}
if overwrite {
if err := resourceRename(tempPath, destPath); err != nil {
return 0, apperrors.NewInternal(fmt.Sprintf("发布消息资源失败: %v", err))
}
return size, nil
}
if err := resourceLink(tempPath, destPath); err != nil {
if errors.Is(err, os.ErrExist) {
return 0, apperrors.NewValidation(
"目标文件已存在;如确认覆盖请显式传 --overwrite")
}
return 0, apperrors.NewInternal(fmt.Sprintf("发布消息资源失败: %v", err))
}
return size, nil
}
func init() {
shortcut.Register(withReviewedChatShortcutContracts(MessagesResourceDownload)...)
}