diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index 64d53bdfd..22b0e07ea 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -137,6 +137,8 @@ func TestDryRunRecordOps(t *testing.T) { "bitable_file", "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1/records/rec_1", "report-final.pdf", + `"mime_type":"\u003cdetected_mime_type\u003e"`, + `"size":"\u003cfile_size\u003e"`, "deprecated_set_attachment", ) } diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 35b8b1e13..e9f432ba2 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -1219,7 +1219,9 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { !strings.Contains(updateBody, `"image_height":480`) || !strings.Contains(updateBody, `"deprecated_set_attachment":true`) || !strings.Contains(updateBody, `"file_token":"file_tok_1"`) || - !strings.Contains(updateBody, `"name":"report.txt"`) { + !strings.Contains(updateBody, `"name":"report.txt"`) || + !strings.Contains(updateBody, `"size":16`) || + !strings.Contains(updateBody, `"mime_type":"text/plain"`) { t.Fatalf("update body=%s", updateBody) } }) @@ -1370,6 +1372,8 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) { if !strings.Contains(updateBody, `"附件"`) || !strings.Contains(updateBody, `"file_token":"file_tok_big"`) || !strings.Contains(updateBody, `"name":"large-report.bin"`) || + !strings.Contains(updateBody, `"size":20971521`) || + !strings.Contains(updateBody, `"mime_type":"application/octet-stream"`) || !strings.Contains(updateBody, `"deprecated_set_attachment":true`) { t.Fatalf("update body=%s", updateBody) } diff --git a/shortcuts/base/record_upload_attachment.go b/shortcuts/base/record_upload_attachment.go index de0615d1f..67fde80f8 100644 --- a/shortcuts/base/record_upload_attachment.go +++ b/shortcuts/base/record_upload_attachment.go @@ -4,11 +4,15 @@ package base import ( + "bytes" "context" "errors" "fmt" + "io" + "mime" "path/filepath" "strings" + "unicode/utf8" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" @@ -105,6 +109,8 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont map[string]interface{}{ "file_token": "", "name": fileName, + "mime_type": "", + "size": "", "deprecated_set_attachment": true, }, }, @@ -243,10 +249,14 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i } func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) { + mimeType, err := detectAttachmentMIMEType(runtime.FileIO(), filePath, fileName) + if err != nil { + return nil, err + } + parentNode := baseToken var ( fileToken string - err error ) if fileSize <= common.MaxDriveMediaUploadSinglePartSize { fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ @@ -272,7 +282,78 @@ func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, attachment := map[string]interface{}{ "file_token": fileToken, "name": fileName, + "mime_type": mimeType, + "size": fileSize, "deprecated_set_attachment": true, } return attachment, nil } + +func detectAttachmentMIMEType(fio fileio.FileIO, filePath, fileName string) (string, error) { + if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(fileName)))); byExt != "" { + return stripMIMEParams(byExt), nil + } + if byExt := strings.TrimSpace(mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))); byExt != "" { + return stripMIMEParams(byExt), nil + } + + f, err := fio.Open(filePath) + if err != nil { + return "", common.WrapInputStatError(err) + } + defer f.Close() + + buf := make([]byte, 512) + n, readErr := f.Read(buf) + if readErr != nil && !errors.Is(readErr, io.EOF) { + return "", output.ErrValidation("cannot read file: %s", readErr) + } + return detectAttachmentMIMEFromContent(buf[:n]), nil +} + +func stripMIMEParams(value string) string { + if i := strings.IndexByte(value, ';'); i != -1 { + value = value[:i] + } + return strings.TrimSpace(value) +} + +func detectAttachmentMIMEFromContent(content []byte) string { + if len(content) == 0 { + return "application/octet-stream" + } + if bytes.HasPrefix(content, []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}) { + return "image/png" + } + if bytes.HasPrefix(content, []byte{0xff, 0xd8, 0xff}) { + return "image/jpeg" + } + if bytes.HasPrefix(content, []byte("GIF87a")) || bytes.HasPrefix(content, []byte("GIF89a")) { + return "image/gif" + } + if len(content) >= 12 && bytes.Equal(content[:4], []byte("RIFF")) && bytes.Equal(content[8:12], []byte("WEBP")) { + return "image/webp" + } + if bytes.HasPrefix(content, []byte("%PDF-")) { + return "application/pdf" + } + if looksLikeText(content) { + return "text/plain" + } + return "application/octet-stream" +} + +func looksLikeText(content []byte) bool { + if !utf8.Valid(content) { + return false + } + for _, r := range string(content) { + if r == '\n' || r == '\r' || r == '\t' { + continue + } + if r < 0x20 || r == 0x7f { + return false + } + } + return true +} diff --git a/shortcuts/base/record_upload_attachment_test.go b/shortcuts/base/record_upload_attachment_test.go new file mode 100644 index 000000000..69ff360e2 --- /dev/null +++ b/shortcuts/base/record_upload_attachment_test.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "bytes" + "io" + "io/fs" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/extension/fileio" +) + +type attachmentTestFileIO struct { + openFile fileio.File + openErr error +} + +func (f attachmentTestFileIO) Open(string) (fileio.File, error) { return f.openFile, f.openErr } +func (attachmentTestFileIO) Stat(string) (fileio.FileInfo, error) { + return attachmentTestFileInfo{}, nil +} +func (attachmentTestFileIO) ResolvePath(path string) (string, error) { return path, nil } +func (attachmentTestFileIO) Save(string, fileio.SaveOptions, io.Reader) (fileio.SaveResult, error) { + return nil, nil +} + +type attachmentTestFileInfo struct{} + +func (attachmentTestFileInfo) Size() int64 { return 0 } +func (attachmentTestFileInfo) IsDir() bool { return false } +func (attachmentTestFileInfo) Mode() fs.FileMode { return 0 } + +type attachmentTestFile struct { + *bytes.Reader +} + +func newAttachmentTestFile(content []byte) attachmentTestFile { + return attachmentTestFile{Reader: bytes.NewReader(content)} +} + +func (attachmentTestFile) Close() error { return nil } + +type attachmentReadErrorFile struct{} + +func (attachmentReadErrorFile) Read([]byte) (int, error) { return 0, os.ErrPermission } +func (attachmentReadErrorFile) ReadAt([]byte, int64) (int, error) { return 0, io.EOF } +func (attachmentReadErrorFile) Close() error { return nil } + +func TestDetectAttachmentMIMETypeUsesExtension(t *testing.T) { + got, err := detectAttachmentMIMEType(nil, "ignored", "note.TXT") + if err != nil { + t.Fatalf("detectAttachmentMIMEType() error = %v", err) + } + if got != "text/plain" { + t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain") + } +} + +func TestDetectAttachmentMIMETypeFallsBackToSourcePathExtension(t *testing.T) { + got, err := detectAttachmentMIMEType(nil, "report.docx", "report") + if err != nil { + t.Fatalf("detectAttachmentMIMEType() error = %v", err) + } + if got != "application/vnd.openxmlformats-officedocument.wordprocessingml.document" { + t.Fatalf("detectAttachmentMIMEType() = %q, want docx MIME type", got) + } +} + +func TestDetectAttachmentMIMETypeFallsBackToContent(t *testing.T) { + fio := attachmentTestFileIO{openFile: newAttachmentTestFile([]byte("hello from base attachment"))} + + got, err := detectAttachmentMIMEType(fio, "note", "note") + if err != nil { + t.Fatalf("detectAttachmentMIMEType() error = %v", err) + } + if got != "text/plain" { + t.Fatalf("detectAttachmentMIMEType() = %q, want %q", got, "text/plain") + } +} + +func TestDetectAttachmentMIMETypeWrapsOpenError(t *testing.T) { + fio := attachmentTestFileIO{openErr: os.ErrNotExist} + + _, err := detectAttachmentMIMEType(fio, "missing", "missing") + if err == nil { + t.Fatal("expected error for open failure") + } + if !strings.Contains(err.Error(), "cannot read file") { + t.Fatalf("error = %v, want wrapped read failure", err) + } +} + +func TestDetectAttachmentMIMETypeReturnsReadError(t *testing.T) { + fio := attachmentTestFileIO{openFile: attachmentReadErrorFile{}} + + _, err := detectAttachmentMIMEType(fio, "broken", "broken") + if err == nil { + t.Fatal("expected error for read failure") + } + if !strings.Contains(err.Error(), "cannot read file") { + t.Fatalf("error = %v, want read failure", err) + } +} + +func TestDetectAttachmentMIMEFromContent(t *testing.T) { + tests := []struct { + name string + content []byte + want string + }{ + {name: "empty", content: nil, want: "application/octet-stream"}, + {name: "png", content: []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, want: "image/png"}, + {name: "jpeg", content: []byte{0xff, 0xd8, 0xff, 0xe0}, want: "image/jpeg"}, + {name: "gif87a", content: []byte("GIF87a"), want: "image/gif"}, + {name: "gif89a", content: []byte("GIF89a"), want: "image/gif"}, + {name: "webp", content: []byte("RIFF1234WEBP"), want: "image/webp"}, + {name: "pdf", content: []byte("%PDF-1.7"), want: "application/pdf"}, + {name: "text", content: []byte("hello from base attachment"), want: "text/plain"}, + {name: "text with newline", content: []byte("hello\nworld\tok"), want: "text/plain"}, + {name: "control bytes", content: []byte{'h', 'i', 0x00}, want: "application/octet-stream"}, + {name: "binary fallback", content: []byte{0x00, 0x01, 0x02, 0x03}, want: "application/octet-stream"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectAttachmentMIMEFromContent(tt.content) + if got != tt.want { + t.Fatalf("detectAttachmentMIMEFromContent() = %q, want %q", got, tt.want) + } + }) + } +}