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
2 changes: 2 additions & 0 deletions shortcuts/base/base_dryrun_ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
}
Expand Down
6 changes: 5 additions & 1 deletion shortcuts/base/base_execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
Expand Down Expand Up @@ -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)
}
Expand Down
83 changes: 82 additions & 1 deletion shortcuts/base/record_upload_attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -105,6 +109,8 @@
map[string]interface{}{
"file_token": "<uploaded_file_token>",
"name": fileName,
"mime_type": "<detected_mime_type>",
"size": "<file_size>",
"deprecated_set_attachment": true,
},
},
Expand Down Expand Up @@ -243,10 +249,14 @@
}

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

Check warning on line 254 in shortcuts/base/record_upload_attachment.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/record_upload_attachment.go#L254

Added line #L254 was not covered by tests
}

parentNode := baseToken
var (
fileToken string
err error
)
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
Expand All @@ -272,7 +282,78 @@
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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

Check warning on line 348 in shortcuts/base/record_upload_attachment.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/base/record_upload_attachment.go#L348

Added line #L348 was not covered by tests
}
for _, r := range string(content) {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}
136 changes: 136 additions & 0 deletions shortcuts/base/record_upload_attachment_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading