mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
796 lines
27 KiB
Go
796 lines
27 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package doc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
const (
|
|
docCoverResourceType = "cover"
|
|
docCoverUploadParent = "docx_image"
|
|
docCoverURLMaxBytes = int64(20 * 1024 * 1024)
|
|
docCoverDownloadName = "cover"
|
|
docCoverURLDownloadName = "cover"
|
|
)
|
|
|
|
type docCoverHTTPStatusCause int
|
|
|
|
func (c docCoverHTTPStatusCause) Error() string {
|
|
return http.StatusText(int(c))
|
|
}
|
|
|
|
type docCoverURLGuardError string
|
|
|
|
func (e docCoverURLGuardError) Error() string {
|
|
return string(e)
|
|
}
|
|
|
|
var docCoverAllowedContentTypes = map[string]string{
|
|
"image/gif": ".gif",
|
|
"image/jpeg": ".jpg",
|
|
"image/png": ".png",
|
|
"image/webp": ".webp",
|
|
}
|
|
|
|
var DocResourceDownload = common.Shortcut{
|
|
Service: "docs",
|
|
Command: "+resource-download",
|
|
Description: "Download a document resource (type=cover downloads the cover image content)",
|
|
Risk: "read",
|
|
Scopes: []string{"docx:document:readonly", "docs:document.media:download"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
Flags: []common.Flag{
|
|
{Name: "doc", Desc: "document URL or document_id", Required: true},
|
|
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
|
|
{Name: "output", Desc: "local save path", Required: true},
|
|
{Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"},
|
|
},
|
|
Validate: validateDocCoverType,
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
|
if err != nil {
|
|
return common.NewDryRunAPI().Set("error", err.Error())
|
|
}
|
|
documentID := docRef.Token
|
|
d := common.NewDryRunAPI()
|
|
if docRef.Kind == "wiki" {
|
|
documentID = "<resolved_docx_token>"
|
|
d.GET("/open-apis/wiki/v2/spaces/get_node").
|
|
Desc("[1] Resolve wiki node to docx document").
|
|
Params(map[string]interface{}{"token": docRef.Token})
|
|
}
|
|
d.GET("/open-apis/docx/v1/documents/:document_id").
|
|
Desc("Read document cover metadata").
|
|
Set("document_id", documentID)
|
|
d.GET("/open-apis/drive/v1/medias/:cover_token/download").
|
|
Desc("Download cover image content").
|
|
Set("cover_token", "<cover.token>").
|
|
Set("output", runtime.Str("output"))
|
|
return d
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
outputPath := runtime.Str("output")
|
|
if _, err := runtime.ResolveSavePath(outputPath); err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
|
}
|
|
|
|
cover, err := getDocCover(runtime, documentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cover.Token == "" {
|
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "document has no cover (cover is empty): %s", common.MaskToken(documentID)).WithParam("--type")
|
|
}
|
|
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Downloading cover: %s\n", common.MaskToken(cover.Token))
|
|
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
|
HttpMethod: http.MethodGet,
|
|
ApiPath: fmt.Sprintf("/open-apis/drive/v1/medias/%s/download", validate.EncodePathSegment(cover.Token)),
|
|
})
|
|
if err != nil {
|
|
return wrapDocNetworkErr(err, "download cover failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
finalPath, _ := autoAppendDocMediaExtension(outputPath, resp.Header, "")
|
|
if finalPath != outputPath {
|
|
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
|
}
|
|
}
|
|
if !runtime.Bool("overwrite") {
|
|
if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil {
|
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output")
|
|
}
|
|
}
|
|
|
|
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
|
ContentType: resp.Header.Get("Content-Type"),
|
|
ContentLength: resp.ContentLength,
|
|
}, resp.Body)
|
|
if err != nil {
|
|
return common.WrapSaveErrorTyped(err)
|
|
}
|
|
savedPath, _ := runtime.ResolveSavePath(finalPath)
|
|
if savedPath == "" {
|
|
savedPath = finalPath
|
|
}
|
|
runtime.Out(map[string]interface{}{
|
|
"document_id": documentID,
|
|
"type": docCoverResourceType,
|
|
"saved_path": savedPath,
|
|
"size_bytes": result.Size(),
|
|
"content_type": resp.Header.Get("Content-Type"),
|
|
"cover": cover.toOutput(),
|
|
}, nil)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var DocResourceUpdate = common.Shortcut{
|
|
Service: "docs",
|
|
Command: "+resource-update",
|
|
Description: "Upload and update a document resource (type=cover)",
|
|
Risk: "write",
|
|
Scopes: []string{"docx:document:readonly", "docx:document:write_only", "docs:document.media:upload"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
Flags: []common.Flag{
|
|
{Name: "doc", Desc: "document URL or document_id", Required: true},
|
|
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
|
|
{Name: "file", Desc: "local image file path (files > 20MB use multipart upload automatically)"},
|
|
{Name: "from-clipboard", Type: "bool", Desc: "read image from system clipboard instead of a local file"},
|
|
{Name: "url", Desc: "HTTPS image URL to download and upload"},
|
|
{Name: "offset-ratio-x", Type: "float64", Desc: "cover horizontal offset ratio"},
|
|
{Name: "offset-ratio-y", Type: "float64", Desc: "cover vertical offset ratio"},
|
|
},
|
|
Validate: validateDocCoverUpdate,
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
|
if err != nil {
|
|
return common.NewDryRunAPI().Set("error", err.Error())
|
|
}
|
|
documentID := docRef.Token
|
|
d := common.NewDryRunAPI()
|
|
if docRef.Kind == "wiki" {
|
|
documentID = "<resolved_docx_token>"
|
|
d.GET("/open-apis/wiki/v2/spaces/get_node").
|
|
Desc("[1] Resolve wiki node to docx document").
|
|
Params(map[string]interface{}{"token": docRef.Token})
|
|
}
|
|
source := docCoverDryRunSource(runtime)
|
|
d.Desc("upload cover image and update document cover").
|
|
POST("/open-apis/drive/v1/medias/upload_all").
|
|
Desc("Upload cover image").
|
|
Body(map[string]interface{}{
|
|
"file": source,
|
|
"file_name": "<cover_file_name>",
|
|
"parent_type": docCoverUploadParent,
|
|
"parent_node": documentID,
|
|
"extra": fmt.Sprintf(`{"drive_route_token":"%s"}`, documentID),
|
|
})
|
|
d.PATCH("/open-apis/docx/v1/documents/:document_id").
|
|
Desc("Update document cover").
|
|
Body(map[string]interface{}{"update_cover": map[string]interface{}{"cover": buildDocCoverUpdateBody("<file_token>", runtime)}})
|
|
d.Set("document_id", documentID)
|
|
if runtime.Str("url") != "" {
|
|
d.Set("url_safety", "HTTPS only; private/loopback/link-local IPs rejected; max 3 redirects; image content-types only; max 20MiB")
|
|
}
|
|
return d
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
source, err := readDocCoverUpdateSource(ctx, runtime)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Uploading cover image: %s (%d bytes)\n", source.FileName, source.FileSize)
|
|
if source.FileSize > common.MaxDriveMediaUploadSinglePartSize {
|
|
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
|
|
}
|
|
|
|
uploadCfg := UploadDocMediaFileConfig{
|
|
FilePath: source.FilePath,
|
|
Reader: source.Reader,
|
|
FileName: source.FileName,
|
|
FileSize: source.FileSize,
|
|
ParentType: docCoverUploadParent,
|
|
ParentNode: documentID,
|
|
DocID: documentID,
|
|
}
|
|
fileToken, err := uploadDocMediaFile(runtime, uploadCfg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(runtime.IO().ErrOut, "File uploaded: %s\n", common.MaskToken(fileToken))
|
|
|
|
coverBody := buildDocCoverUpdateBody(fileToken, runtime)
|
|
if _, err := runtime.CallAPITyped("PATCH",
|
|
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
|
|
nil,
|
|
map[string]interface{}{"update_cover": map[string]interface{}{"cover": coverBody}},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
runtime.Out(map[string]interface{}{
|
|
"document_id": documentID,
|
|
"type": docCoverResourceType,
|
|
"updated": true,
|
|
"source": source.Kind,
|
|
"file_token": fileToken,
|
|
"cover": coverBody,
|
|
}, nil)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var DocResourceDelete = common.Shortcut{
|
|
Service: "docs",
|
|
Command: "+resource-delete",
|
|
Description: "Delete a document resource (type=cover is idempotent when empty)",
|
|
Risk: "write",
|
|
Scopes: []string{"docx:document:readonly", "docx:document:write_only"},
|
|
AuthTypes: []string{"user", "bot"},
|
|
Flags: []common.Flag{
|
|
{Name: "doc", Desc: "document URL or document_id", Required: true},
|
|
{Name: "type", Default: docCoverResourceType, Desc: "resource type: cover"},
|
|
},
|
|
Validate: validateDocCoverType,
|
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
|
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
|
if err != nil {
|
|
return common.NewDryRunAPI().Set("error", err.Error())
|
|
}
|
|
documentID := docRef.Token
|
|
d := common.NewDryRunAPI()
|
|
if docRef.Kind == "wiki" {
|
|
documentID = "<resolved_docx_token>"
|
|
d.GET("/open-apis/wiki/v2/spaces/get_node").
|
|
Desc("[1] Resolve wiki node to docx document").
|
|
Params(map[string]interface{}{"token": docRef.Token})
|
|
}
|
|
d.GET("/open-apis/docx/v1/documents/:document_id").
|
|
Desc("Read document cover metadata for idempotency").
|
|
Set("document_id", documentID)
|
|
d.PATCH("/open-apis/docx/v1/documents/:document_id").
|
|
Desc("Clear document cover when one exists").
|
|
Body(map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}})
|
|
return d
|
|
},
|
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
documentID, err := resolveDocxDocumentIDForResource(runtime, runtime.Str("doc"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cover, err := getDocCover(runtime, documentID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cover.Token == "" {
|
|
runtime.Out(map[string]interface{}{
|
|
"document_id": documentID,
|
|
"type": docCoverResourceType,
|
|
"deleted": false,
|
|
"already_empty": true,
|
|
}, nil)
|
|
return nil
|
|
}
|
|
|
|
if _, err := runtime.CallAPITyped("PATCH",
|
|
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
|
|
nil,
|
|
map[string]interface{}{"update_cover": map[string]interface{}{"cover": nil}},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
runtime.Out(map[string]interface{}{
|
|
"document_id": documentID,
|
|
"type": docCoverResourceType,
|
|
"deleted": true,
|
|
"already_empty": false,
|
|
"previous_cover": cover.toOutput(),
|
|
}, nil)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
type docCoverMetadata struct {
|
|
Token string
|
|
OffsetRatioX *float64
|
|
OffsetRatioY *float64
|
|
}
|
|
|
|
func (c docCoverMetadata) toOutput() map[string]interface{} {
|
|
out := map[string]interface{}{"token": c.Token}
|
|
if c.OffsetRatioX != nil {
|
|
out["offset_ratio_x"] = *c.OffsetRatioX
|
|
}
|
|
if c.OffsetRatioY != nil {
|
|
out["offset_ratio_y"] = *c.OffsetRatioY
|
|
}
|
|
return out
|
|
}
|
|
|
|
type docCoverUpdateSource struct {
|
|
Kind string
|
|
FilePath string
|
|
Reader io.Reader
|
|
FileName string
|
|
FileSize int64
|
|
}
|
|
|
|
func validateDocCoverType(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
if runtime.Str("type") != docCoverResourceType {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --type %q, expected cover", runtime.Str("type")).WithParam("--type")
|
|
}
|
|
docRef, err := parseDocumentRef(runtime.Str("doc"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if docRef.Kind == "doc" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateDocCoverUpdate(ctx context.Context, runtime *common.RuntimeContext) error {
|
|
if err := validateDocCoverType(ctx, runtime); err != nil {
|
|
return err
|
|
}
|
|
sourceCount := 0
|
|
var params []errs.InvalidParam
|
|
if runtime.Str("file") != "" {
|
|
sourceCount++
|
|
params = append(params, errs.InvalidParam{Name: "--file", Reason: "source flag"})
|
|
}
|
|
if runtime.Bool("from-clipboard") {
|
|
sourceCount++
|
|
params = append(params, errs.InvalidParam{Name: "--from-clipboard", Reason: "source flag"})
|
|
}
|
|
if runtime.Str("url") != "" {
|
|
sourceCount++
|
|
params = append(params, errs.InvalidParam{Name: "--url", Reason: "source flag"})
|
|
}
|
|
if sourceCount == 0 {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file, --from-clipboard or --url is required").WithParams(
|
|
errs.InvalidParam{Name: "--file", Reason: "provide one source"},
|
|
errs.InvalidParam{Name: "--from-clipboard", Reason: "provide one source"},
|
|
errs.InvalidParam{Name: "--url", Reason: "provide one source"},
|
|
)
|
|
}
|
|
if sourceCount > 1 {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file, --from-clipboard and --url are mutually exclusive").WithParams(params...)
|
|
}
|
|
if err := validateCoverOffset(runtime, "offset-ratio-x"); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCoverOffset(runtime, "offset-ratio-y"); err != nil {
|
|
return err
|
|
}
|
|
if rawURL := runtime.Str("url"); rawURL != "" {
|
|
if _, err := parseDocCoverURLSyntax(rawURL); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCoverOffset(runtime *common.RuntimeContext, name string) error {
|
|
if !runtime.Changed(name) {
|
|
return nil
|
|
}
|
|
value := runtime.Float64(name)
|
|
if math.IsNaN(value) || math.IsInf(value, 0) {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s must be a finite number", name).WithParam("--" + name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func resolveDocxDocumentIDForResource(runtime *common.RuntimeContext, input string) (string, error) {
|
|
docRef, err := parseDocumentRef(input)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
switch docRef.Kind {
|
|
case "docx":
|
|
return docRef.Token, nil
|
|
case "wiki":
|
|
return resolveDocxDocumentID(runtime, input)
|
|
case "doc":
|
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
|
|
default:
|
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs resource-* only supports docx documents").WithParam("--doc")
|
|
}
|
|
}
|
|
|
|
func getDocCover(runtime *common.RuntimeContext, documentID string) (docCoverMetadata, error) {
|
|
data, err := runtime.CallAPITyped("GET",
|
|
fmt.Sprintf("/open-apis/docx/v1/documents/%s", validate.EncodePathSegment(documentID)),
|
|
nil, nil)
|
|
if err != nil {
|
|
return docCoverMetadata{}, err
|
|
}
|
|
coverData := common.GetMap(data, "document", "cover")
|
|
if len(coverData) == 0 {
|
|
coverData = common.GetMap(data, "cover")
|
|
}
|
|
cover := docCoverMetadata{Token: common.GetString(coverData, "token")}
|
|
if value, ok := getOptionalFloat(coverData, "offset_ratio_x"); ok {
|
|
cover.OffsetRatioX = &value
|
|
}
|
|
if value, ok := getOptionalFloat(coverData, "offset_ratio_y"); ok {
|
|
cover.OffsetRatioY = &value
|
|
}
|
|
return cover, nil
|
|
}
|
|
|
|
func getOptionalFloat(m map[string]interface{}, key string) (float64, bool) {
|
|
if m == nil {
|
|
return 0, false
|
|
}
|
|
switch v := m[key].(type) {
|
|
case float64:
|
|
return v, true
|
|
case int:
|
|
return float64(v), true
|
|
case int64:
|
|
return float64(v), true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func readDocCoverUpdateSource(ctx context.Context, runtime *common.RuntimeContext) (docCoverUpdateSource, error) {
|
|
if runtime.Bool("from-clipboard") {
|
|
fmt.Fprintf(runtime.IO().ErrOut, "Reading image from clipboard...\n")
|
|
content, err := readClipboardImage()
|
|
if err != nil {
|
|
return docCoverUpdateSource{}, err
|
|
}
|
|
return docCoverUpdateSource{
|
|
Kind: "clipboard",
|
|
Reader: bytes.NewReader(content),
|
|
FileName: "clipboard.png",
|
|
FileSize: int64(len(content)),
|
|
}, nil
|
|
}
|
|
if rawURL := runtime.Str("url"); rawURL != "" {
|
|
content, fileName, err := downloadDocCoverURL(ctx, runtime, rawURL)
|
|
if err != nil {
|
|
return docCoverUpdateSource{}, err
|
|
}
|
|
return docCoverUpdateSource{
|
|
Kind: "url",
|
|
Reader: bytes.NewReader(content),
|
|
FileName: fileName,
|
|
FileSize: int64(len(content)),
|
|
}, nil
|
|
}
|
|
|
|
filePath := runtime.Str("file")
|
|
stat, err := runtime.FileIO().Stat(filePath)
|
|
if err != nil {
|
|
return docCoverUpdateSource{}, wrapDocInputFileErr(err, "file not found")
|
|
}
|
|
if !stat.Mode().IsRegular() {
|
|
return docCoverUpdateSource{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file")
|
|
}
|
|
return docCoverUpdateSource{
|
|
Kind: "file",
|
|
FilePath: filePath,
|
|
FileName: filepath.Base(filePath),
|
|
FileSize: stat.Size(),
|
|
}, nil
|
|
}
|
|
|
|
func buildDocCoverUpdateBody(fileToken string, runtime *common.RuntimeContext) map[string]interface{} {
|
|
cover := map[string]interface{}{"token": fileToken}
|
|
if runtime.Changed("offset-ratio-x") {
|
|
cover["offset_ratio_x"] = runtime.Float64("offset-ratio-x")
|
|
}
|
|
if runtime.Changed("offset-ratio-y") {
|
|
cover["offset_ratio_y"] = runtime.Float64("offset-ratio-y")
|
|
}
|
|
return cover
|
|
}
|
|
|
|
func docCoverDryRunSource(runtime *common.RuntimeContext) string {
|
|
if runtime.Bool("from-clipboard") {
|
|
return "<clipboard image>"
|
|
}
|
|
if rawURL := runtime.Str("url"); rawURL != "" {
|
|
return rawURL
|
|
}
|
|
if filePath := runtime.Str("file"); filePath != "" {
|
|
return "@" + filePath
|
|
}
|
|
return "<cover image>"
|
|
}
|
|
|
|
func downloadDocCoverURL(ctx context.Context, runtime *common.RuntimeContext, raw string) ([]byte, string, error) {
|
|
u, err := parseAndValidateDocCoverURL(ctx, raw)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
baseClient, err := runtime.Factory.HttpClient()
|
|
if err != nil {
|
|
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "http client: %v", err).WithCause(err)
|
|
}
|
|
client := newDocCoverHTTPClient(baseClient)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) //nolint:forbidigo // cover --url fetches external user content; RuntimeContext API helpers are Lark-API only.
|
|
if err != nil {
|
|
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --url: %v", err).WithParam("--url").WithCause(err)
|
|
}
|
|
resp, err := client.Do(req) //nolint:forbidigo // cover --url uses a guarded external downloader, not Lark API transport.
|
|
if err != nil {
|
|
return nil, "", wrapDocNetworkErr(err, "download cover URL failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
subtype := errs.SubtypeNetworkTransport
|
|
if resp.StatusCode >= 500 {
|
|
subtype = errs.SubtypeNetworkServer
|
|
}
|
|
cause := docCoverHTTPStatusCause(resp.StatusCode)
|
|
return nil, "", errs.NewNetworkError(subtype, "download cover URL failed: HTTP %d", resp.StatusCode).WithCode(resp.StatusCode).WithCause(cause)
|
|
}
|
|
|
|
mediaType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
|
|
if err != nil || mediaType == "" {
|
|
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL response must include an image Content-Type").WithParam("--url")
|
|
}
|
|
mediaType = strings.ToLower(mediaType)
|
|
ext, ok := docCoverAllowedContentTypes[mediaType]
|
|
if !ok {
|
|
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL Content-Type %q is not supported; expected image/png, image/jpeg, image/gif or image/webp", mediaType).WithParam("--url")
|
|
}
|
|
|
|
limited := io.LimitReader(resp.Body, docCoverURLMaxBytes+1)
|
|
content, err := io.ReadAll(limited)
|
|
if err != nil {
|
|
return nil, "", wrapDocNetworkErr(err, "read cover URL response failed: %v", err)
|
|
}
|
|
if int64(len(content)) > docCoverURLMaxBytes {
|
|
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL response exceeds 20MiB limit").WithParam("--url")
|
|
}
|
|
|
|
fileName := docCoverURLFileName(resp.Request.URL, ext)
|
|
return content, fileName, nil
|
|
}
|
|
|
|
func parseAndValidateDocCoverURL(ctx context.Context, raw string) (*url.URL, error) {
|
|
u, err := parseDocCoverURLSyntax(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateDocCoverURLHost(ctx, u.Hostname()); err != nil {
|
|
return nil, err
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func parseDocCoverURLSyntax(raw string) (*url.URL, error) {
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --url: %v", err).WithParam("--url").WithCause(err)
|
|
}
|
|
if u.Scheme != "https" {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must use https").WithParam("--url")
|
|
}
|
|
if u.User != nil {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not include userinfo").WithParam("--url")
|
|
}
|
|
host := u.Hostname()
|
|
if host == "" {
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url host cannot be empty").WithParam("--url")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func docCoverURLFileName(u *url.URL, ext string) string {
|
|
base := path.Base(u.EscapedPath())
|
|
if base == "." || base == "/" || base == "" {
|
|
return docCoverURLDownloadName + ext
|
|
}
|
|
unescaped, err := url.PathUnescape(base)
|
|
if err == nil {
|
|
base = unescaped
|
|
}
|
|
base = filepath.Base(base)
|
|
if strings.TrimSpace(base) == "" || base == "." || base == string(filepath.Separator) {
|
|
return docCoverURLDownloadName + ext
|
|
}
|
|
if filepath.Ext(base) == "" {
|
|
base += ext
|
|
}
|
|
return base
|
|
}
|
|
|
|
func validateDocCoverURLHost(ctx context.Context, host string) error {
|
|
host = strings.TrimSpace(strings.ToLower(host))
|
|
if host == "" {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url host cannot be empty").WithParam("--url")
|
|
}
|
|
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
|
|
}
|
|
if ip := net.ParseIP(host); ip != nil {
|
|
if isUnsafeDocCoverIP(ip) {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
|
|
}
|
|
return nil
|
|
}
|
|
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
|
if err != nil {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to resolve --url host: %v", err).WithParam("--url").WithCause(err)
|
|
}
|
|
if len(ips) == 0 {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to resolve --url host: no addresses").WithParam("--url")
|
|
}
|
|
for _, ip := range ips {
|
|
if isUnsafeDocCoverIP(ip) {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--url must not resolve to a local or internal address").WithParam("--url")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isUnsafeDocCoverIP(ip net.IP) bool {
|
|
if ip == nil {
|
|
return true
|
|
}
|
|
if ip.IsLoopback() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
|
|
return true
|
|
}
|
|
if v4 := ip.To4(); v4 != nil {
|
|
if v4[0] == 10 || v4[0] == 127 {
|
|
return true
|
|
}
|
|
if v4[0] == 169 && v4[1] == 254 {
|
|
return true
|
|
}
|
|
if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 {
|
|
return true
|
|
}
|
|
if v4[0] == 192 && v4[1] == 168 {
|
|
return true
|
|
}
|
|
if v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
|
|
return true
|
|
}
|
|
if v4[0] == 198 && (v4[1] == 18 || v4[1] == 19) {
|
|
return true
|
|
}
|
|
if v4[0] >= 240 {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
return ip.IsPrivate()
|
|
}
|
|
|
|
func newDocCoverHTTPClient(base *http.Client) *http.Client { //nolint:forbidigo // guarded external --url downloader cannot use Lark API runtime helpers.
|
|
if base == nil {
|
|
base = &http.Client{} //nolint:forbidigo // fallback only; caller normally supplies Factory.HttpClient.
|
|
}
|
|
cloned := *base
|
|
if cloned.Timeout == 0 { //nolint:forbidigo // external download timeout guard on cloned client.
|
|
cloned.Timeout = 30 * time.Second //nolint:forbidigo // external download timeout guard on cloned client.
|
|
}
|
|
cloned.Transport = cloneDocCoverTransport(base.Transport) //nolint:forbidigo // external download transport adds proxy/IP guards.
|
|
cloned.CheckRedirect = func(req *http.Request, via []*http.Request) error { //nolint:forbidigo // redirects must be validated for external --url downloads.
|
|
if len(via) >= 3 {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirects too many times").WithParam("--url")
|
|
}
|
|
if len(via) > 0 {
|
|
prev := via[len(via)-1]
|
|
if strings.EqualFold(prev.URL.Scheme, "https") && strings.EqualFold(req.URL.Scheme, "http") {
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cover URL redirect from https to http is not allowed").WithParam("--url")
|
|
}
|
|
}
|
|
_, err := parseAndValidateDocCoverURL(req.Context(), req.URL.String())
|
|
return err
|
|
}
|
|
return &cloned
|
|
}
|
|
|
|
func cloneDocCoverTransport(base http.RoundTripper) *http.Transport { //nolint:forbidigo // external --url downloader wraps caller transport with IP/proxy guards.
|
|
var cloned *http.Transport
|
|
if src, ok := base.(*http.Transport); ok && src != nil {
|
|
cloned = src.Clone()
|
|
} else if def, ok := http.DefaultTransport.(*http.Transport); ok && def != nil { //nolint:forbidigo // fallback for guarded external downloader only.
|
|
cloned = def.Clone()
|
|
} else {
|
|
cloned = &http.Transport{}
|
|
}
|
|
cloned.Proxy = nil
|
|
|
|
origDial := cloned.DialContext
|
|
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
conn, err := dialDocCoverConn(ctx, origDial, network, addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateDocCoverConnRemoteIP(conn); err != nil {
|
|
conn.Close()
|
|
return nil, err
|
|
}
|
|
return conn, nil
|
|
}
|
|
if cloned.DialTLSContext != nil {
|
|
origDialTLS := cloned.DialTLSContext
|
|
cloned.DialTLSContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
conn, err := dialDocCoverConn(ctx, origDialTLS, network, addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateDocCoverConnRemoteIP(conn); err != nil {
|
|
conn.Close()
|
|
return nil, err
|
|
}
|
|
return conn, nil
|
|
}
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
func dialDocCoverConn(ctx context.Context, dialFn func(context.Context, string, string) (net.Conn, error), network, addr string) (net.Conn, error) {
|
|
if dialFn != nil {
|
|
return dialFn(ctx, network, addr)
|
|
}
|
|
var dialer net.Dialer
|
|
return dialer.DialContext(ctx, network, addr)
|
|
}
|
|
|
|
func validateDocCoverConnRemoteIP(conn net.Conn) error {
|
|
if conn == nil {
|
|
return docCoverURLGuardError("nil connection")
|
|
}
|
|
addr := conn.RemoteAddr()
|
|
if addr == nil {
|
|
return docCoverURLGuardError("missing remote address")
|
|
}
|
|
host, _, err := net.SplitHostPort(addr.String())
|
|
if err != nil {
|
|
host = addr.String()
|
|
}
|
|
ip := net.ParseIP(strings.Trim(host, "[]"))
|
|
if ip == nil {
|
|
return docCoverURLGuardError("invalid remote IP")
|
|
}
|
|
if isUnsafeDocCoverIP(ip) {
|
|
return docCoverURLGuardError("local/internal host is not allowed")
|
|
}
|
|
return nil
|
|
}
|