bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
261 lines
8.8 KiB
Go
261 lines
8.8 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package common
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/client"
|
|
)
|
|
|
|
const MaxDriveMediaUploadSinglePartSize int64 = 20 * 1024 * 1024 // 20MB
|
|
|
|
const (
|
|
driveMediaUploadAllAction = "upload media failed"
|
|
driveMediaUploadPartAction = "upload media part failed"
|
|
driveMediaUploadFinishAction = "upload media finish failed"
|
|
)
|
|
|
|
type DriveMediaMultipartUploadSession struct {
|
|
UploadID string
|
|
BlockSize int64
|
|
BlockNum int
|
|
}
|
|
|
|
type DriveMediaUploadAllConfig struct {
|
|
FilePath string
|
|
FileName string
|
|
FileSize int64
|
|
ParentType string
|
|
ParentNode *string
|
|
Extra string
|
|
// Reader, when non-nil, is used as the upload source instead of opening
|
|
// FilePath. Callers must set FileName and FileSize explicitly. The reader
|
|
// is NOT closed by UploadDriveMediaAllTyped; the caller owns its lifetime.
|
|
// Used by the clipboard path in docs +media-insert.
|
|
Reader io.Reader
|
|
}
|
|
|
|
type DriveMediaMultipartUploadConfig struct {
|
|
FilePath string
|
|
FileName string
|
|
FileSize int64
|
|
ParentType string
|
|
ParentNode string
|
|
Extra string
|
|
// Reader mirrors DriveMediaUploadAllConfig.Reader for chunked uploads.
|
|
Reader io.Reader
|
|
}
|
|
|
|
// UploadDriveMediaAllTyped uploads a file in a single request: file-open
|
|
// failures surface as typed validation errors, transport failures as typed
|
|
// network errors, and API failures are classified via ClassifyAPIResponse so
|
|
// subtype / code / log_id survive on the error.
|
|
func UploadDriveMediaAllTyped(runtime *RuntimeContext, cfg DriveMediaUploadAllConfig) (string, error) {
|
|
var fileReader io.Reader
|
|
if cfg.Reader != nil {
|
|
fileReader = cfg.Reader
|
|
} else {
|
|
f, err := runtime.FileIO().Open(cfg.FilePath)
|
|
if err != nil {
|
|
return "", WrapInputStatErrorTyped(err)
|
|
}
|
|
defer f.Close()
|
|
fileReader = f
|
|
}
|
|
|
|
fd := larkcore.NewFormdata()
|
|
fd.AddField("file_name", cfg.FileName)
|
|
fd.AddField("parent_type", cfg.ParentType)
|
|
fd.AddField("size", fmt.Sprintf("%d", cfg.FileSize))
|
|
if cfg.ParentNode != nil {
|
|
fd.AddField("parent_node", *cfg.ParentNode)
|
|
}
|
|
if cfg.Extra != "" {
|
|
fd.AddField("extra", cfg.Extra)
|
|
}
|
|
fd.AddFile("file", fileReader)
|
|
|
|
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
|
HttpMethod: http.MethodPost,
|
|
ApiPath: "/open-apis/drive/v1/medias/upload_all",
|
|
Body: fd,
|
|
}, larkcore.WithFileUpload())
|
|
if err != nil {
|
|
return "", prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadAllAction)
|
|
}
|
|
|
|
data, err := runtime.ClassifyAPIResponse(apiResp)
|
|
if err != nil {
|
|
return "", prefixDriveMediaUploadProblem(err, driveMediaUploadAllAction)
|
|
}
|
|
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadAllAction)
|
|
}
|
|
|
|
// UploadDriveMediaMultipartTyped uploads a file in server-planned chunks:
|
|
// prepare/finish failures come back typed from CallAPITyped, malformed session
|
|
// plans surface as invalid-response internal errors, and per-part
|
|
// transport/API failures are classified the same way as
|
|
// UploadDriveMediaAllTyped.
|
|
func UploadDriveMediaMultipartTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig) (string, error) {
|
|
// upload_prepare expects parent_node to be present even when the caller wants
|
|
// the service default/root behavior, so multipart callers pass an explicit
|
|
// string instead of relying on field omission like upload_all does.
|
|
prepareBody := map[string]interface{}{
|
|
"file_name": cfg.FileName,
|
|
"parent_type": cfg.ParentType,
|
|
"parent_node": cfg.ParentNode,
|
|
"size": cfg.FileSize,
|
|
}
|
|
if cfg.Extra != "" {
|
|
prepareBody["extra"] = cfg.Extra
|
|
}
|
|
|
|
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_prepare", nil, prepareBody)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
session, err := parseDriveMediaMultipartUploadSessionTyped(data)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, FormatSize(session.BlockSize))
|
|
|
|
if err = uploadDriveMediaMultipartPartsTyped(runtime, cfg, session); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return finishDriveMediaMultipartUploadTyped(runtime, session.UploadID, session.BlockNum)
|
|
}
|
|
|
|
// prefixDriveMediaUploadProblem prepends the upload action to a typed error's
|
|
// message so callers see which upload step failed. Non-typed errors are
|
|
// returned unchanged.
|
|
func prefixDriveMediaUploadProblem(err error, action string) error {
|
|
if p, ok := errs.ProblemOf(err); ok {
|
|
p.Message = action + ": " + p.Message
|
|
}
|
|
return err
|
|
}
|
|
|
|
// parseDriveMediaMultipartUploadSessionTyped validates the upload_prepare
|
|
// session plan, reporting a malformed plan as a typed invalid-response
|
|
// internal error.
|
|
func parseDriveMediaMultipartUploadSessionTyped(data map[string]interface{}) (DriveMediaMultipartUploadSession, error) {
|
|
// The backend chooses both chunk size and chunk count. Validate them once so
|
|
// the streaming loop can follow the returned plan without re-checking shape.
|
|
session := DriveMediaMultipartUploadSession{
|
|
UploadID: GetString(data, "upload_id"),
|
|
BlockSize: int64(GetFloat(data, "block_size")),
|
|
BlockNum: int(GetFloat(data, "block_num")),
|
|
}
|
|
if session.UploadID == "" {
|
|
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: no upload_id returned")
|
|
}
|
|
if session.BlockSize <= 0 {
|
|
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
|
|
}
|
|
if session.BlockNum <= 0 {
|
|
return DriveMediaMultipartUploadSession{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_num returned")
|
|
}
|
|
return session, nil
|
|
}
|
|
|
|
// extractDriveMediaUploadFileTokenTyped reads the file_token from a successful
|
|
// upload response, reporting a missing file_token as a typed invalid-response
|
|
// internal error.
|
|
func extractDriveMediaUploadFileTokenTyped(data map[string]interface{}, action string) (string, error) {
|
|
fileToken := GetString(data, "file_token")
|
|
if fileToken == "" {
|
|
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "%s: no file_token returned", action)
|
|
}
|
|
return fileToken, nil
|
|
}
|
|
|
|
// uploadDriveMediaMultipartPartsTyped streams the file in server-planned
|
|
// chunks, with typed errors for file-open, file-read, and per-part upload
|
|
// failures.
|
|
func uploadDriveMediaMultipartPartsTyped(runtime *RuntimeContext, cfg DriveMediaMultipartUploadConfig, session DriveMediaMultipartUploadSession) error {
|
|
var r io.Reader
|
|
if cfg.Reader != nil {
|
|
r = cfg.Reader
|
|
} else {
|
|
f, err := runtime.FileIO().Open(cfg.FilePath)
|
|
if err != nil {
|
|
return WrapInputStatErrorTyped(err)
|
|
}
|
|
defer f.Close()
|
|
r = f
|
|
}
|
|
|
|
maxInt := int64(^uint(0) >> 1)
|
|
bufferSize := session.BlockSize
|
|
if bufferSize <= 0 || bufferSize > maxInt {
|
|
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload prepare failed: invalid block_size returned")
|
|
}
|
|
buffer := make([]byte, int(bufferSize))
|
|
remaining := cfg.FileSize
|
|
// Follow the server-declared block plan exactly; upload_finish expects the
|
|
// same block count returned by upload_prepare.
|
|
for seq := 0; seq < session.BlockNum; seq++ {
|
|
chunkSize := session.BlockSize
|
|
if remaining > 0 && chunkSize > remaining {
|
|
chunkSize = remaining
|
|
}
|
|
|
|
n, readErr := io.ReadFull(r, buffer[:int(chunkSize)])
|
|
if readErr != nil {
|
|
return WrapInputStatErrorTyped(readErr)
|
|
}
|
|
|
|
if err := uploadDriveMediaMultipartPartTyped(runtime, session.UploadID, seq, buffer[:n]); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, FormatSize(int64(n)))
|
|
remaining -= int64(n)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func uploadDriveMediaMultipartPartTyped(runtime *RuntimeContext, uploadID string, seq int, chunk []byte) error {
|
|
fd := larkcore.NewFormdata()
|
|
fd.AddField("upload_id", uploadID)
|
|
fd.AddField("seq", fmt.Sprintf("%d", seq))
|
|
fd.AddField("size", fmt.Sprintf("%d", len(chunk)))
|
|
fd.AddFile("file", bytes.NewReader(chunk))
|
|
|
|
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
|
|
HttpMethod: http.MethodPost,
|
|
ApiPath: "/open-apis/drive/v1/medias/upload_part",
|
|
Body: fd,
|
|
}, larkcore.WithFileUpload())
|
|
if err != nil {
|
|
return prefixDriveMediaUploadProblem(client.WrapDoAPIError(err), driveMediaUploadPartAction)
|
|
}
|
|
|
|
if _, err := runtime.ClassifyAPIResponse(apiResp); err != nil {
|
|
return prefixDriveMediaUploadProblem(err, driveMediaUploadPartAction)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func finishDriveMediaMultipartUploadTyped(runtime *RuntimeContext, uploadID string, blockNum int) (string, error) {
|
|
data, err := runtime.CallAPITyped("POST", "/open-apis/drive/v1/medias/upload_finish", nil, map[string]interface{}{
|
|
"upload_id": uploadID,
|
|
"block_num": blockNum,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return extractDriveMediaUploadFileTokenTyped(data, driveMediaUploadFinishAction)
|
|
}
|