Files
dingtalk-workspace-cli/internal/shortcut/chat/message_export.go

121 lines
4.6 KiB
Go

// 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.
package chat
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
// ValidateMessageExportOutput applies the same workspace-relative and
// no-symlink boundary used by message resource downloads. The export target is
// always a file; directory-shaped paths are rejected instead of inventing a
// name.
func ValidateMessageExportOutput(output string) error {
trimmed := strings.TrimSpace(output)
if strings.HasSuffix(trimmed, "/") ||
strings.HasSuffix(trimmed, string(os.PathSeparator)) {
return apperrors.NewValidation("--output 必须是 JSON 文件路径,不能是目录")
}
if !strings.EqualFold(filepath.Ext(trimmed), ".json") {
return apperrors.NewValidation("--output 必须使用 .json 文件扩展名")
}
return validateResourceDownloadOutputFlag(output, "--output")
}
// WriteMessageExportJSON atomically publishes the exact structured message
// ledger. It defaults to no-clobber and never follows a symlink outside the
// current working directory.
func WriteMessageExportJSON(output string, overwrite bool, payload any) (relativePath string, size int, err error) {
if err := ValidateMessageExportOutput(output); err != nil {
return "", 0, err
}
cwd, err := resourceGetwd()
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
base, err := resourceAbs(cwd)
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
realBase, err := resourceEvalSymlinks(base)
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
target := filepath.Join(realBase, filepath.Clean(strings.TrimSpace(output)))
parent := filepath.Dir(target)
if err := ensureResourceDownloadParent(realBase, parent); err != nil {
return "", 0, err
}
realParent, err := resourceEvalSymlinks(parent)
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析输出目录失败: %v", err))
}
parentRel, err := resourceRel(realBase, realParent)
if err != nil || parentRel == ".." || strings.HasPrefix(parentRel, ".."+string(os.PathSeparator)) {
return "", 0, apperrors.NewValidation("--output 解析后逃逸工作目录")
}
target = filepath.Join(realParent, filepath.Base(target))
if info, statErr := resourceLstat(target); statErr == nil {
if info.Mode()&os.ModeSymlink != 0 {
return "", 0, apperrors.NewValidation("--output 目标不能是符号链接")
}
if info.IsDir() {
return "", 0, apperrors.NewValidation("--output 目标是目录,无法写入 JSON 文件")
}
if !overwrite {
return "", 0, apperrors.NewValidation("目标文件已存在;如确认覆盖请显式传 --overwrite")
}
} else if !errors.Is(statErr, os.ErrNotExist) {
return "", 0, apperrors.NewInternal(fmt.Sprintf("检查输出文件失败: %v", statErr))
}
rendered, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("编码消息导出失败: %v", err))
}
rendered = append(rendered, '\n')
temp, err := resourceCreateTemp(realParent, "."+filepath.Base(target)+".part-*")
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("创建消息导出临时文件失败: %v", err))
}
tempPath := temp.Name()
defer func() {
_ = resourceTempClose(temp)
_ = os.Remove(tempPath)
}()
if _, err := temp.Write(rendered); err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("写入消息导出失败: %v", err))
}
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, target); err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("发布消息导出失败: %v", err))
}
} else if err := resourceLink(tempPath, target); err != nil {
if errors.Is(err, os.ErrExist) {
return "", 0, apperrors.NewValidation("目标文件已存在;如确认覆盖请显式传 --overwrite")
}
return "", 0, apperrors.NewInternal(fmt.Sprintf("发布消息导出失败: %v", err))
}
relativePath, err = resourceRel(realBase, target)
if err != nil {
return "", 0, apperrors.NewInternal(fmt.Sprintf("解析输出相对路径失败: %v", err))
}
return filepath.ToSlash(relativePath), len(rendered), nil
}