Compare commits

...

1 Commits

Author SHA1 Message Date
fangshuyu
f437118473 fix: centralize resource URL parsing 2026-07-09 10:18:49 +08:00
11 changed files with 128 additions and 125 deletions

View File

@@ -271,6 +271,9 @@ func resolveBaseURL(u *url.URL) map[string]interface{} {
func resolveWikiBaseURL(runtime *common.RuntimeContext, u *url.URL) (map[string]interface{}, error) {
token := firstPathSegmentAfter(u.Path, "/wiki/")
if err := runtime.EnsureScopes([]string{"wiki:node:retrieve"}); err != nil {
return nil, err
}
data, err := runtime.CallAPITyped("GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": token}, nil)
if err != nil {
return nil, err
@@ -518,14 +521,8 @@ func pathSegmentExists(path, prefix string) bool {
func firstPathSegmentAfter(path, prefix string) string {
path = normalizeResolvePath(path)
if !strings.HasPrefix(path, prefix) {
return ""
}
rest := path[len(prefix):]
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
rest = rest[:idx]
}
return strings.TrimSpace(rest)
token, _ := common.PathSegmentAfterPrefix(path, prefix)
return token
}
func valueOrUnknown(s string) string {

View File

@@ -78,6 +78,7 @@ var urlPathToType = []struct {
{"/chat/drive/", "folder"},
{"/docx/", "docx"},
{"/doc/", "doc"},
{"/spreadsheets/", "sheet"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
@@ -117,21 +118,30 @@ func ParseResourceURL(rawURL string) (ResourceRef, bool) {
path := u.Path
for _, mapping := range urlPathToType {
if !strings.HasPrefix(path, mapping.Prefix) {
continue
if token, ok := PathSegmentAfterPrefix(path, mapping.Prefix); ok {
return ResourceRef{Type: mapping.Type, Token: token}, true
}
token := path[len(mapping.Prefix):]
// Trim trailing slashes and stop at the next path segment boundary.
token = strings.TrimRight(token, "/")
if idx := strings.IndexByte(token, '/'); idx >= 0 {
token = token[:idx]
}
token = strings.TrimSpace(token)
if token == "" {
return ResourceRef{}, false
}
return ResourceRef{Type: mapping.Type, Token: token}, true
}
return ResourceRef{}, false
}
// PathSegmentAfterPrefix returns the first non-empty path segment after prefix.
// It only matches when path itself starts with prefix; query strings and
// fragments must be parsed by the caller and are intentionally ignored.
func PathSegmentAfterPrefix(path, prefix string) (string, bool) {
if !strings.HasPrefix(path, prefix) {
return "", false
}
rest := path[len(prefix):]
// Trim trailing slashes and stop at the next path segment boundary.
rest = strings.TrimRight(rest, "/")
if idx := strings.IndexByte(rest, '/'); idx >= 0 {
rest = rest[:idx]
}
rest = strings.TrimSpace(rest)
if rest == "" {
return "", false
}
return rest, true
}

View File

@@ -23,6 +23,7 @@ func TestParseResourceURL(t *testing.T) {
{"docx", "https://xxx.feishu.cn/docx/doxcnABC", "docx", "doxcnABC", true},
{"doc", "https://xxx.feishu.cn/doc/doccnABC", "doc", "doccnABC", true},
{"sheet", "https://xxx.feishu.cn/sheets/shtcnABC", "sheet", "shtcnABC", true},
{"sheet via /spreadsheets/", "https://xxx.feishu.cn/spreadsheets/shtcnABC", "sheet", "shtcnABC", true},
{"bitable via /base/", "https://xxx.feishu.cn/base/bascnABC", "bitable", "bascnABC", true},
{"bitable via /bitable/", "https://xxx.feishu.cn/bitable/bascnABC", "bitable", "bascnABC", true},
{"wiki", "https://xxx.feishu.cn/wiki/wikcnABC", "wiki", "wikcnABC", true},
@@ -41,6 +42,8 @@ func TestParseResourceURL(t *testing.T) {
// With query parameters
{"with query", "https://xxx.feishu.cn/docx/doxcnABC?from=wiki", "docx", "doxcnABC", true},
{"with fragment", "https://xxx.feishu.cn/docx/doxcnABC#section", "docx", "doxcnABC", true},
{"query path does not hijack type", "https://xxx.feishu.cn/docx/doxcnABC?next=/wiki/wikcnBAD", "docx", "doxcnABC", true},
{"fragment path does not hijack type", "https://xxx.feishu.cn/sheets/shtcnABC#/wiki/wikcnBAD", "sheet", "shtcnABC", true},
// With trailing slash
{"trailing slash", "https://xxx.feishu.cn/docx/doxcnABC/", "docx", "doxcnABC", true},

View File

@@ -49,7 +49,10 @@ var DocMediaInsert = common.Shortcut{
Description: "Insert a local image or file into a Lark document (4-step orchestration + auto-rollback); appends to end by default, or inserts relative to a text selection with --selection-with-ellipsis",
Risk: "write",
Scopes: []string{"docs:document.media:upload", "docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
ConditionalScopes: []string{
"wiki:node:retrieve",
},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)"},
{Name: "from-clipboard", Type: "bool", Desc: "read image from system clipboard instead of a local file (macOS/Windows built-in; Linux requires xclip, xsel or wl-paste)"},
@@ -534,6 +537,9 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc")
case "wiki":
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
if err := runtime.EnsureScopes([]string{"wiki:node:retrieve"}); err != nil {
return "", err
}
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",

View File

@@ -59,7 +59,10 @@ var DocResourceDownload = common.Shortcut{
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"},
ConditionalScopes: []string{
"wiki:node:retrieve",
},
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"},
@@ -158,7 +161,10 @@ var DocResourceUpdate = common.Shortcut{
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"},
ConditionalScopes: []string{
"wiki:node:retrieve",
},
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"},
@@ -260,7 +266,10 @@ var DocResourceDelete = common.Shortcut{
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"},
ConditionalScopes: []string{
"wiki:node:retrieve",
},
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"},

View File

@@ -27,14 +27,13 @@ func parseDocumentRef(input string) (documentRef, error) {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc")
}
if token, ok := extractDocumentToken(raw, "/wiki/"); ok {
return documentRef{Kind: "wiki", Token: token}, nil
}
if token, ok := extractDocumentToken(raw, "/docx/"); ok {
return documentRef{Kind: "docx", Token: token}, nil
}
if token, ok := extractDocumentToken(raw, "/doc/"); ok {
return documentRef{Kind: "doc", Token: token}, nil
if ref, ok := common.ParseResourceURL(raw); ok {
switch ref.Type {
case "wiki", "docx", "doc":
return documentRef{Kind: ref.Type, Token: ref.Token}, nil
default:
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw).WithParam("--doc")
}
}
if strings.Contains(raw, "://") {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw).WithParam("--doc")
@@ -46,22 +45,6 @@ func parseDocumentRef(input string) (documentRef, error) {
return documentRef{Kind: "docx", Token: raw}, nil
}
func extractDocumentToken(raw, marker string) (string, bool) {
idx := strings.Index(raw, marker)
if idx < 0 {
return "", false
}
token := raw[idx+len(marker):]
if end := strings.IndexAny(token, "/?#"); end >= 0 {
token = token[:end]
}
token = strings.TrimSpace(token)
if token == "" {
return "", false
}
return token, true
}
// doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns
// the parsed "data" field from the standard Lark response envelope {code, msg, data}.
// CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id

View File

@@ -31,6 +31,18 @@ func TestParseDocumentRef(t *testing.T) {
wantKind: "wiki",
wantToken: "xxxxxx",
},
{
name: "query path does not hijack docx url",
input: "https://example.larksuite.com/docx/doxcn123?next=/wiki/wikcnBAD",
wantKind: "docx",
wantToken: "doxcn123",
},
{
name: "fragment path does not hijack docx url",
input: "https://example.larksuite.com/docx/doxcn123#/wiki/wikcnBAD",
wantKind: "docx",
wantToken: "doxcn123",
},
{
name: "doc url",
input: "https://example.larksuite.com/doc/xxxxxx",

View File

@@ -129,7 +129,8 @@ var DriveAddComment = common.Shortcut{
"docs:document.comment:create",
"docs:document.comment:write_only",
},
AuthTypes: []string{"user", "bot"},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides/base/bitable URL, or wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", Required: true},
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides, bitable, base (required when --doc is a bare token; auto-detected for URLs; use bitable as the wire value, base is accepted as a compatibility alias)", Enum: []string{"doc", "docx", "file", "sheet", "slides", "bitable", "base"}},
@@ -515,29 +516,17 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc")
}
if token, ok := extractURLToken(raw, "/wiki/"); ok {
return commentDocRef{Kind: "wiki", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/sheets/"); ok {
return commentDocRef{Kind: "sheet", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/base/"); ok {
return commentDocRef{Kind: "base", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/bitable/"); ok {
return commentDocRef{Kind: "base", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/file/"); ok {
return commentDocRef{Kind: "file", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/slides/"); ok {
return commentDocRef{Kind: "slides", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/docx/"); ok {
return commentDocRef{Kind: "docx", Token: token}, nil
}
if token, ok := extractURLToken(raw, "/doc/"); ok {
return commentDocRef{Kind: "doc", Token: token}, nil
if ref, ok := common.ParseResourceURL(raw); ok {
kind := ref.Type
if kind == "bitable" {
kind = "base"
}
switch kind {
case "wiki", "sheet", "base", "file", "slides", "docx", "doc":
return commentDocRef{Kind: kind, Token: ref.Token}, nil
default:
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides/base/bitable URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw).WithParam("--doc")
}
}
if strings.Contains(raw, "://") {
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides/base/bitable URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw).WithParam("--doc")
@@ -583,6 +572,9 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token))
if err := runtime.EnsureScopes([]string{"wiki:node:retrieve"}); err != nil {
return resolvedCommentTarget{}, err
}
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
@@ -1257,19 +1249,3 @@ func executeSlidesComment(runtime *common.RuntimeContext, docRef commentDocRef)
runtime.Out(out, nil)
return nil
}
func extractURLToken(raw, marker string) (string, bool) {
idx := strings.Index(raw, marker)
if idx < 0 {
return "", false
}
token := raw[idx+len(marker):]
if end := strings.IndexAny(token, "/?#"); end >= 0 {
token = token[:end]
}
token = strings.TrimSpace(token)
if token == "" {
return "", false
}
return token, true
}

View File

@@ -98,6 +98,18 @@ func TestParseCommentDocRef(t *testing.T) {
wantKind: "wiki",
wantToken: "xxxxxx",
},
{
name: "query path does not hijack docx url",
input: "https://example.larksuite.com/docx/doxcn123?next=/wiki/wikcnBAD",
wantKind: "docx",
wantToken: "doxcn123",
},
{
name: "fragment path does not hijack sheet url",
input: "https://example.larksuite.com/sheets/sht123#/wiki/wikcnBAD",
wantKind: "sheet",
wantToken: "sht123",
},
{
name: "raw token with type docx",
input: "xxxxxx",
@@ -2080,14 +2092,6 @@ func TestParseCommentDocRefPathLikeToken(t *testing.T) {
}
}
func TestExtractURLTokenEmptyAfterMarker(t *testing.T) {
t.Parallel()
_, ok := extractURLToken("https://example.com/sheets/", "/sheets/")
if ok {
t.Fatal("expected false for empty token after marker")
}
}
func TestSheetCommentExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{

View File

@@ -20,24 +20,6 @@ var permApplyTypes = []string{
"mindnote", "slides",
}
// permApplyURLMarkers maps document URL path markers to the `type` value the
// apply-permission endpoint expects. Markers are disjoint strings (each begins
// with "/" and ends with "/"), so a simple substring scan disambiguates them.
var permApplyURLMarkers = []struct {
Marker string
Type string
}{
{"/wiki/", "wiki"},
{"/docx/", "docx"},
{"/sheets/", "sheet"},
{"/base/", "bitable"},
{"/bitable/", "bitable"},
{"/file/", "file"},
{"/mindnote/", "mindnote"},
{"/slides/", "slides"},
{"/doc/", "doc"},
}
// resolvePermApplyTarget extracts (token, type) from a user-supplied --token
// value that may be either a bare token or a full document URL, plus an
// optional explicit --type. Explicit --type wins over URL inference.
@@ -48,21 +30,17 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
}
if strings.Contains(raw, "://") {
for _, m := range permApplyURLMarkers {
if tok, ok := extractURLToken(raw, m.Marker); ok {
token = tok
if explicitType == "" {
docType = m.Type
}
break
}
}
if token == "" {
ref, ok := common.ParseResourceURL(raw)
if !ok {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"could not infer token from URL %q: supported paths are /docx/, /sheets/, /base/, /bitable/, /file/, /wiki/, /doc/, /mindnote/, /slides/. Pass a bare token with --type instead if the URL shape is unusual",
raw,
).WithParam("--token")
}
token = ref.Token
if explicitType == "" {
docType = ref.Type
}
} else {
token = raw
}
@@ -76,9 +54,25 @@ func resolvePermApplyTarget(raw, explicitType string) (token, docType string, er
strings.Join(permApplyTypes, ", "),
).WithParam("--type")
}
if !isPermApplyType(docType) {
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"unsupported --type %q; accepted values: %s",
docType,
strings.Join(permApplyTypes, ", "),
).WithParam("--type")
}
return token, docType, nil
}
func isPermApplyType(docType string) bool {
for _, allowed := range permApplyTypes {
if docType == allowed {
return true
}
}
return false
}
// DriveApplyPermission applies to the document owner for view or edit access
// on behalf of the invoking user. Matches the open-apis endpoint
// /open-apis/drive/v1/permissions/:token/members/apply.

View File

@@ -42,6 +42,7 @@ func TestResolvePermApplyTarget_URLInference(t *testing.T) {
wantType string
}{
{"docx", "https://example.feishu.cn/docx/doxTok123?from=share", "doxTok123", "docx"},
{"query path does not hijack docx url", "https://example.feishu.cn/docx/doxTok123?next=/wiki/wikTokBAD", "doxTok123", "docx"},
{"sheets", "https://example.feishu.cn/sheets/shtTok456?sheet=abc", "shtTok456", "sheet"},
{"base", "https://example.feishu.cn/base/bscTok789", "bscTok789", "bitable"},
{"bitable", "https://example.feishu.cn/bitable/bscTok789", "bscTok789", "bitable"},
@@ -86,6 +87,14 @@ func TestResolvePermApplyTarget_UnrecognizedURL(t *testing.T) {
}
}
func TestResolvePermApplyTarget_UnsupportedURLType(t *testing.T) {
t.Parallel()
_, _, err := resolvePermApplyTarget("https://example.feishu.cn/drive/folder/fldTok123", "")
if err == nil || !strings.Contains(err.Error(), "unsupported --type") {
t.Fatalf("expected unsupported type error, got: %v", err)
}
}
func TestResolvePermApplyTarget_Empty(t *testing.T) {
t.Parallel()
_, _, err := resolvePermApplyTarget(" ", "docx")