mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +08:00
internal/util imported internal/proxyplugin (SharedTransport, FallbackTransport, NewHTTPClient, and WarnIfProxied via proxyPluginStatus), so a foundational util package depended up into a feature package, pulling binding/core/vfs into the transitive cone of every util importer. Move internal/proxyplugin -> internal/transport and make it the single owner of outbound transport: fold the two SharedTransport functions into one Shared() (proxy-plugin override -> LARK_CLI_NO_PROXY -> http.DefaultTransport), and move Fallback/NewHTTPClient/WarnIfProxied/DetectProxyEnv/noProxyTransport out of the now-deleted internal/util/proxy.go into the new package. The proxy-plugin probe is demoted to a private pluginTransport(); the duplicate redactProxyURL collapses to one. internal/util keeps no proxy code and is a leaf again. Re-point all consumers (registry, doctor, config, auth, cmdutil, update) to internal/transport. Behavior-preserving: package move + symbol rename + dedup. Two new tests lock the fail-closed contract (plugin overrides NO_PROXY; malformed config never falls through to direct egress).
323 lines
7.9 KiB
Go
323 lines
7.9 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package registry
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/internal/build"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/transport"
|
|
"github.com/larksuite/cli/internal/validate"
|
|
"github.com/larksuite/cli/internal/vfs"
|
|
)
|
|
|
|
const (
|
|
defaultMetaTTL = 86400 // seconds (24h)
|
|
maxResponseSize = 10 << 20 // 10 MB
|
|
fetchTimeout = 5 * time.Second
|
|
)
|
|
|
|
// CacheMeta holds metadata about the cached remote_meta.json file.
|
|
type CacheMeta struct {
|
|
LastCheckAt int64 `json:"last_check_at"`
|
|
Version string `json:"version,omitempty"`
|
|
Brand string `json:"brand,omitempty"`
|
|
}
|
|
|
|
// MergedRegistry is the top-level structure of remote_meta.json.
|
|
type MergedRegistry struct {
|
|
Version string `json:"version"`
|
|
Services []map[string]interface{} `json:"services"`
|
|
}
|
|
|
|
// remoteResponse is the envelope returned by the remote API.
|
|
type remoteResponse struct {
|
|
Msg string `json:"msg"`
|
|
Data MergedRegistry `json:"data"`
|
|
}
|
|
|
|
// configuredBrand is set by InitWithBrand and determines which API host to use.
|
|
var configuredBrand core.LarkBrand
|
|
|
|
// --- configuration helpers ---
|
|
|
|
// enableRemoteMeta controls whether remote API meta fetching is active.
|
|
// Flip to true when ready to roll out.
|
|
var enableRemoteMeta = true
|
|
|
|
func remoteEnabled() bool {
|
|
if !enableRemoteMeta {
|
|
return false
|
|
}
|
|
return os.Getenv("LARKSUITE_CLI_REMOTE_META") != "off"
|
|
}
|
|
|
|
// testMetaURL overrides the remote meta URL for testing.
|
|
var testMetaURL string
|
|
|
|
func remoteMetaURL(version string) string {
|
|
if testMetaURL != "" {
|
|
return testMetaURL
|
|
}
|
|
var base string
|
|
switch configuredBrand {
|
|
case core.BrandLark:
|
|
base = "https://open.larksuite.com/api/tools/open/api_definition"
|
|
default:
|
|
base = "https://open.feishu.cn/api/tools/open/api_definition"
|
|
}
|
|
q := "protocol=meta&client_version=" + url.QueryEscape(build.Version)
|
|
if version != "" {
|
|
q += "&data_version=" + url.QueryEscape(version)
|
|
}
|
|
return base + "?" + q
|
|
}
|
|
|
|
func metaTTL() time.Duration {
|
|
if s := os.Getenv("LARKSUITE_CLI_META_TTL"); s != "" {
|
|
if n, err := strconv.Atoi(s); err == nil && n >= 0 {
|
|
return time.Duration(n) * time.Second
|
|
}
|
|
}
|
|
return defaultMetaTTL * time.Second
|
|
}
|
|
|
|
// --- cache path helpers ---
|
|
|
|
func cacheDir() string {
|
|
return filepath.Join(core.GetConfigDir(), "cache")
|
|
}
|
|
|
|
func cachePath() string {
|
|
return filepath.Join(cacheDir(), "remote_meta.json")
|
|
}
|
|
|
|
func cacheMetaPath() string {
|
|
return filepath.Join(cacheDir(), "remote_meta.meta.json")
|
|
}
|
|
|
|
// cacheWritable checks if the cache directory is writable.
|
|
// Returns false if the directory cannot be created or written to.
|
|
func cacheWritable() bool {
|
|
dir := cacheDir()
|
|
if err := vfs.MkdirAll(dir, 0700); err != nil {
|
|
return false
|
|
}
|
|
probe := filepath.Join(dir, ".probe")
|
|
if err := vfs.WriteFile(probe, []byte{}, 0644); err != nil {
|
|
return false
|
|
}
|
|
vfs.Remove(probe)
|
|
return true
|
|
}
|
|
|
|
// --- cache I/O ---
|
|
|
|
func loadCacheMeta() (CacheMeta, error) {
|
|
var meta CacheMeta
|
|
data, err := vfs.ReadFile(cacheMetaPath())
|
|
if err != nil {
|
|
return meta, err
|
|
}
|
|
if err = json.Unmarshal(data, &meta); err != nil {
|
|
return meta, err
|
|
}
|
|
return meta, nil
|
|
}
|
|
|
|
func saveCacheMeta(meta CacheMeta) error {
|
|
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.Marshal(meta)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return validate.AtomicWrite(cacheMetaPath(), data, 0644)
|
|
}
|
|
|
|
func loadCachedMerged() (*MergedRegistry, error) {
|
|
path := cachePath()
|
|
data, err := vfs.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var reg MergedRegistry
|
|
if err := json.Unmarshal(data, ®); err != nil {
|
|
// Cache corrupted — remove it so next run triggers a fresh fetch
|
|
vfs.Remove(path)
|
|
vfs.Remove(cacheMetaPath())
|
|
return nil, err
|
|
}
|
|
return ®, nil
|
|
}
|
|
|
|
func saveCachedMerged(data []byte, meta CacheMeta) error {
|
|
if err := vfs.MkdirAll(cacheDir(), 0700); err != nil {
|
|
return err
|
|
}
|
|
if err := validate.AtomicWrite(cachePath(), data, 0644); err != nil {
|
|
return err
|
|
}
|
|
return saveCacheMeta(meta)
|
|
}
|
|
|
|
// --- HTTP fetch ---
|
|
|
|
// fetchRemoteMerged fetches the remote API definition.
|
|
// localVersion is sent as data_version query param for server-side version comparison.
|
|
// Returns (data, reg, err). A nil reg means the version is unchanged (not modified).
|
|
func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, err error) {
|
|
// Route through the shared proxy-plugin-aware transport so remote API
|
|
// definition fetches honor proxy plugin mode instead of bypassing it.
|
|
client := transport.NewHTTPClient(fetchTimeout)
|
|
req, err := http.NewRequest("GET", remoteMetaURL(localVersion), nil)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, nil, &httpError{StatusCode: resp.StatusCode}
|
|
}
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Parse the envelope response
|
|
var envelope remoteResponse
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if envelope.Msg != "succeeded" {
|
|
return nil, nil, fmt.Errorf("remote meta: unexpected msg %q", envelope.Msg)
|
|
}
|
|
|
|
// If data.Services is nil, the version is up-to-date (not modified)
|
|
if envelope.Data.Services == nil {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
// Re-marshal just the data portion for caching
|
|
dataBytes, err := json.Marshal(envelope.Data)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return dataBytes, &envelope.Data, nil
|
|
}
|
|
|
|
type httpError struct {
|
|
StatusCode int
|
|
}
|
|
|
|
func (e *httpError) Error() string {
|
|
return "remote meta: HTTP " + strconv.Itoa(e.StatusCode)
|
|
}
|
|
|
|
// --- sync fetch (no embedded, no cache) ---
|
|
|
|
// doSyncFetch performs a blocking fetch for first-run without embedded data.
|
|
func doSyncFetch() {
|
|
fmt.Fprintf(os.Stderr, "Fetching API metadata...\n")
|
|
data, reg, err := fetchRemoteMerged(embeddedVersion)
|
|
if err != nil || reg == nil {
|
|
// Write meta even on failure so we don't retry every invocation within TTL
|
|
_ = saveCacheMeta(CacheMeta{
|
|
LastCheckAt: time.Now().Unix(),
|
|
Brand: string(configuredBrand),
|
|
})
|
|
return
|
|
}
|
|
meta := CacheMeta{
|
|
LastCheckAt: time.Now().Unix(),
|
|
Version: reg.Version,
|
|
Brand: string(configuredBrand),
|
|
}
|
|
_ = saveCachedMerged(data, meta)
|
|
overlayMergedServices(reg)
|
|
}
|
|
|
|
// --- background refresh ---
|
|
|
|
var (
|
|
refreshOnce sync.Once
|
|
bgRefreshInFlight sync.WaitGroup // tracks doBackgroundRefresh goroutines for test teardown (resetInit)
|
|
)
|
|
|
|
func triggerBackgroundRefresh() {
|
|
refreshOnce.Do(func() {
|
|
bgRefreshInFlight.Add(1)
|
|
go func() {
|
|
defer bgRefreshInFlight.Done()
|
|
doBackgroundRefresh()
|
|
}()
|
|
})
|
|
}
|
|
|
|
func doBackgroundRefresh() {
|
|
defer func() { _ = recover() }()
|
|
meta, _ := loadCacheMeta()
|
|
version := meta.Version
|
|
if version == "" {
|
|
version = embeddedVersion
|
|
}
|
|
data, reg, err := fetchRemoteMerged(version)
|
|
if err != nil {
|
|
// On error, update last_check_at to avoid retrying every invocation
|
|
meta.LastCheckAt = time.Now().Unix()
|
|
_ = saveCacheMeta(meta)
|
|
return
|
|
}
|
|
if reg == nil {
|
|
// Version unchanged — just update check time
|
|
meta.LastCheckAt = time.Now().Unix()
|
|
_ = saveCacheMeta(meta)
|
|
return
|
|
}
|
|
newMeta := CacheMeta{
|
|
LastCheckAt: time.Now().Unix(),
|
|
Version: reg.Version,
|
|
Brand: string(configuredBrand),
|
|
}
|
|
_ = saveCachedMerged(data, newMeta)
|
|
}
|
|
|
|
// shouldRefresh returns true if the cache TTL has expired.
|
|
func shouldRefresh(meta CacheMeta) bool {
|
|
if meta.LastCheckAt == 0 {
|
|
return true
|
|
}
|
|
return time.Since(time.Unix(meta.LastCheckAt, 0)) > metaTTL()
|
|
}
|
|
|
|
// overlayMergedServices merges remote services into the in-memory map.
|
|
// Remote entries override embedded entries with the same name.
|
|
func overlayMergedServices(reg *MergedRegistry) {
|
|
for _, svc := range reg.Services {
|
|
name, ok := svc["name"].(string)
|
|
if !ok || name == "" {
|
|
continue
|
|
}
|
|
mergedServices[name] = svc
|
|
}
|
|
}
|