diff --git a/Makefile b/Makefile index 9ec32ce..2a66d09 100644 --- a/Makefile +++ b/Makefile @@ -2,12 +2,23 @@ HAS_UPX := $(shell command -v upx 2> /dev/null) .PHONY: build -build: +build: ## Build for current platform (cross-compile support: linux-amd64, darwin-arm64) go build -ldflags="-X main.version=v2-`git rev-parse --short HEAD`" -o ./feishu2md cmd/*.go ifneq ($(and $(COMPRESS),$(HAS_UPX)),) upx -9 ./feishu2md endif +.PHONY: build-linux-amd64 +build-linux-amd64: ## Build for Linux AMD64 + GOOS=linux GOARCH=amd64 go build -ldflags="-X main.version=v2-$$(git rev-parse --short HEAD)" -o ./feishu2md-linux-amd64 cmd/*.go + +.PHONY: build-darwin-arm64 +build-darwin-arm64: ## Build for macOS ARM64 + GOOS=darwin GOARCH=arm64 go build -ldflags="-X main.version=v2-$$(git rev-parse --short HEAD)" -o ./feishu2md-darwin-arm64 cmd/*.go + +.PHONY: build-all-platforms +build-all-platforms: build build-linux-amd64 build-darwin-arm64 ## Build for all platforms + .PHONY: test test: go test ./... diff --git a/cmd/download.go b/cmd/download.go index a73064f..cc5ef96 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "sync" + "time" "github.com/88250/lute" "github.com/Wsine/feishu2md/core" @@ -25,6 +26,27 @@ type DownloadOpts struct { var dlOpts = DownloadOpts{} var dlConfig core.Config +// isTokenExpired checks if the user token is expired +func isTokenExpired(cfg *core.Config) bool { + if cfg.Feishu.TokenExpireTime == 0 { + return true + } + return time.Now().Unix() >= cfg.Feishu.TokenExpireTime +} + +// loadConfigWithRefresh loads config and returns updated config if token was refreshed +func loadConfigWithRefresh() (*core.Config, error) { + configPath, err := core.GetConfigFilePath() + if err != nil { + return nil, err + } + config, err := core.ReadConfigFromFile(configPath) + if err != nil { + return nil, err + } + return config, nil +} + func downloadDocument(ctx context.Context, client *core.Client, url string, opts *DownloadOpts) error { // Validate the url to download docType, docToken, err := utils.ValidateDocumentURL(url) @@ -246,20 +268,28 @@ func downloadWiki(ctx context.Context, client *core.Client, url string) error { func handleDownloadCommand(url string) error { // Load config - configPath, err := core.GetConfigFilePath() - if err != nil { - return err - } - config, err := core.ReadConfigFromFile(configPath) + config, err := loadConfigWithRefresh() if err != nil { return err } dlConfig = *config - // Instantiate the client - client := core.NewClient( - dlConfig.Feishu.AppId, dlConfig.Feishu.AppSecret, - ) + // Create client based on token availability + var client *core.Client + if config.Feishu.UserAccessToken != "" && !isTokenExpired(config) { + client = core.NewClientWithUserToken( + config.Feishu.AppId, + config.Feishu.AppSecret, + config.Feishu.UserAccessToken, + ) + fmt.Println("Using user identity for download") + } else { + client = core.NewClient( + config.Feishu.AppId, + config.Feishu.AppSecret, + ) + fmt.Println("Using app identity for download") + } ctx := context.Background() if dlOpts.batch { diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 0000000..4400434 --- /dev/null +++ b/cmd/login.go @@ -0,0 +1,203 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "net/http" + "os/exec" + "runtime" + "time" + + "github.com/Wsine/feishu2md/core" +) + +type LoginOpts struct { + port int +} + +var loginOpts = LoginOpts{port: 8088} + +func handleLoginCommand() error { + // 1. Load config to get app_id and app_secret + configPath, err := core.GetConfigFilePath() + if err != nil { + return fmt.Errorf("failed to get config path: %w", err) + } + + cfg, err := core.ReadConfigFromFile(configPath) + if err != nil { + return fmt.Errorf("failed to read config: %w", err) + } + + if cfg.Feishu.AppId == "" || cfg.Feishu.AppSecret == "" { + return fmt.Errorf("app_id and app_secret are required in config, please run 'feishu2md config --appId --appSecret ' first") + } + + // 2. Generate PKCE + codeVerifier, codeChallenge, err := core.GeneratePKCE() + if err != nil { + return fmt.Errorf("failed to generate PKCE: %w", err) + } + + // 3. Generate random state for CSRF protection + state, err := generateState() + if err != nil { + return fmt.Errorf("failed to generate state: %w", err) + } + + // 4. Build auth URL + authURL := core.BuildAuthURL(cfg.Feishu.AppId, state, codeChallenge) + + // 5. Display URL in terminal + fmt.Println("Please visit the following URL to login:") + fmt.Println(authURL) + fmt.Println() + + // 6. Open browser (platform-specific) + if err := openBrowser(authURL); err != nil { + fmt.Printf("Failed to open browser: %v\n", err) + fmt.Println("Please manually open the URL above in your browser.") + } + + // 7. Start callback server + fmt.Printf("Waiting for callback on http://127.0.0.1:%d/callback...\n", loginOpts.port) + + done := make(chan error, 1) + go func() { + done <- startCallbackServer(loginOpts.port, codeVerifier, configPath, cfg, state) + }() + + // Wait for callback or timeout + select { + case err := <-done: + if err != nil { + return err + } + fmt.Println("Login successful! Tokens have been saved to config.") + return nil + case <-time.After(5 * time.Minute): + return fmt.Errorf("login timed out after 5 minutes") + } +} + +func startCallbackServer(port int, codeVerifier string, configPath string, cfg *core.Config, expectedState string) error { + mux := http.NewServeMux() + + // Use a channel to signal callback completion with error + callbackDone := make(chan error, 1) + + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + // Check for error in callback + if errMsg := query.Get("error"); errMsg != "" { + callbackDone <- fmt.Errorf("oauth error: %s - %s", errMsg, query.Get("error_description")) + return + } + + // Validate state + state := query.Get("state") + if state != expectedState { + callbackDone <- fmt.Errorf("state mismatch: expected %s, got %s", expectedState, state) + return + } + + code := query.Get("code") + if code == "" { + callbackDone <- fmt.Errorf("no code received in callback") + return + } + + // Exchange code for tokens + token, err := core.ExchangeCodeForToken(cfg.Feishu.AppId, cfg.Feishu.AppSecret, code, codeVerifier) + if err != nil { + callbackDone <- fmt.Errorf("failed to exchange code for token: %w", err) + return + } + + // Update config with tokens + cfg.Feishu.UserAccessToken = token.AccessToken + cfg.Feishu.RefreshToken = token.RefreshToken + cfg.Feishu.TokenExpireTime = time.Now().Unix() + int64(token.ExpiresIn) + + // Save config + if err := cfg.WriteConfig2File(configPath); err != nil { + callbackDone <- fmt.Errorf("failed to save config: %w", err) + return + } + + // Signal success + callbackDone <- nil + + // Return success HTML + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + + Login Successful + + + +
+

Login Successful!

+

You can now close this window and use feishu2md commands.

+
+ +`)) + }) + + server := &http.Server{ + Addr: fmt.Sprintf("127.0.0.1:%d", port), + Handler: mux, + } + + // Start server in goroutine + errChan := make(chan error, 1) + go func() { + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errChan <- err + } + }() + + // Wait for either callback completion or error + select { + case callbackErr := <-callbackDone: + server.Close() + return callbackErr + case err := <-errChan: + return fmt.Errorf("server error: %w", err) + } +} + +func openBrowser(url string) error { + var cmd string + + switch runtime.GOOS { + case "linux": + cmd = "xdg-open" + case "darwin": + cmd = "open" + case "windows": + cmd = "rundll32" + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + + return exec.Command(cmd, url).Start() +} + +func generateState() (string, error) { + bytes := make([]byte, 16) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} diff --git a/cmd/main.go b/cmd/main.go index f045038..d10ab14 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -82,6 +82,21 @@ func main() { } }, }, + { + Name: "login", + Usage: "Login to Feishu to enable user-level permissions", + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "port", + Value: 8088, + Usage: "Port for OAuth callback server", + Destination: &loginOpts.port, + }, + }, + Action: func(ctx *cli.Context) error { + return handleLoginCommand() + }, + }, }, } diff --git a/core/auth.go b/core/auth.go new file mode 100644 index 0000000..33a208e --- /dev/null +++ b/core/auth.go @@ -0,0 +1,142 @@ +package core + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + authEndpoint = "https://open.feishu.cn/open-apis/authen/v1/authorize" + redirectURI = "http://127.0.0.1:8088/callback" + scope = "docx:document:readonly drive:drive:readonly wiki:wiki:readonly" +) + +var ( + tokenEndpoint = "https://open.feishu.cn/open-apis/authen/v1/oidc/access_token" + defaultClient = &http.Client{Timeout: 30 * time.Second} +) + +type OAuthToken struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` +} + +func GeneratePKCE() (verifier string, challenge string, err error) { + // Generate 32 random bytes for code verifier + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", "", err + } + verifier = base64.RawURLEncoding.EncodeToString(bytes) + + // Generate S256 code challenge using SHA256 hash + hash := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(hash[:]) + + return verifier, challenge, nil +} + +func BuildAuthURL(appID, state, codeChallenge string) string { + params := url.Values{} + params.Set("app_id", appID) + params.Set("redirect_uri", redirectURI) + params.Set("scope", scope) + params.Set("response_type", "code") + params.Set("state", state) + params.Set("code_challenge", codeChallenge) + params.Set("code_challenge_method", "S256") + + return fmt.Sprintf("%s?%s", authEndpoint, params.Encode()) +} + +func ExchangeCodeForToken(clientID, clientSecret, code, codeVerifier string) (*OAuthToken, error) { + if code == "" { + return nil, errors.New("code is required") + } + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("app_id", clientID) + data.Set("client_secret", clientSecret) + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + data.Set("code_verifier", codeVerifier) + + return doTokenRequest(data) +} + +func RefreshUserToken(clientID, clientSecret, refreshToken string) (*OAuthToken, error) { + if refreshToken == "" { + return nil, errors.New("refreshToken is required") + } + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("app_id", clientID) + data.Set("client_secret", clientSecret) + data.Set("refresh_token", refreshToken) + + return doTokenRequest(data) +} + +func doTokenRequest(data url.Values) (*OAuthToken, error) { + // Use HTTP Basic Auth for client_secret only, keep app_id in body + var clientSecret string + appID := data.Get("app_id") + if appID != "" { + clientSecret = data.Get("client_secret") + data.Del("client_secret") // Only remove secret from body, keep app_id + } + + bodyReader := strings.NewReader(data.Encode()) + req, err := http.NewRequest(http.MethodPost, tokenEndpoint, bodyReader) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + if clientSecret != "" { + creds := base64.StdEncoding.EncodeToString([]byte(appID + ":" + clientSecret)) + req.Header.Set("Authorization", "Basic "+creds) + } + + resp, err := defaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10MB limit + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token request failed with status %d: %s", resp.StatusCode, string(respBody)) + } + + var result struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data OAuthToken `json:"data"` + } + + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, err + } + + if result.Code != 0 { + return nil, fmt.Errorf("token request failed: code=%d, msg=%s", result.Code, result.Msg) + } + + return &result.Data, nil +} diff --git a/core/auth_test.go b/core/auth_test.go new file mode 100644 index 0000000..2087e4c --- /dev/null +++ b/core/auth_test.go @@ -0,0 +1,246 @@ +package core + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestGeneratePKCE(t *testing.T) { + verifier, challenge, err := GeneratePKCE() + if err != nil { + t.Fatalf("GeneratePKCE() error = %v", err) + } + + // Verify verifier is 32 bytes base64 rawurl encoded + verifierBytes, err := base64.RawURLEncoding.DecodeString(verifier) + if err != nil { + t.Fatalf("verifier is not valid base64: %v", err) + } + if len(verifierBytes) != 32 { + t.Errorf("verifier length = %d, want 32", len(verifierBytes)) + } + + // Verify challenge is S256 hash of verifier + hash := sha256.Sum256([]byte(verifier)) + expectedChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) + if challenge != expectedChallenge { + t.Errorf("challenge = %s, want %s", challenge, expectedChallenge) + } + + // Generate another pair to ensure they're different (random) + verifier2, challenge2, err := GeneratePKCE() + if err != nil { + t.Fatalf("GeneratePKCE() second call error = %v", err) + } + if verifier == verifier2 { + t.Error("two calls to GeneratePKCE produced same verifier") + } + if challenge == challenge2 { + t.Error("two calls to GeneratePKCE produced same challenge") + } +} + +func TestBuildAuthURL(t *testing.T) { + appID := "test-app-id" + state := "test-state" + challenge := "test-challenge" + + urlStr := BuildAuthURL(appID, state, challenge) + + parsed, err := url.Parse(urlStr) + if err != nil { + t.Fatalf("BuildAuthURL returned invalid URL: %v", err) + } + + if parsed.Scheme != "https" { + t.Errorf("scheme = %s, want https", parsed.Scheme) + } + if parsed.Host != "open.feishu.cn" { + t.Errorf("host = %s, want open.feishu.cn", parsed.Host) + } + if parsed.Path != "/open-apis/authen/v1/authorize" { + t.Errorf("path = %s, want /open-apis/authen/v1/authorize", parsed.Path) + } + + params := parsed.Query() + if params.Get("app_id") != appID { + t.Errorf("app_id = %s, want %s", params.Get("app_id"), appID) + } + if params.Get("redirect_uri") != redirectURI { + t.Errorf("redirect_uri = %s, want %s", params.Get("redirect_uri"), redirectURI) + } + if params.Get("scope") != scope { + t.Errorf("scope = %s, want %s", params.Get("scope"), scope) + } + if params.Get("response_type") != "code" { + t.Errorf("response_type = %s, want code", params.Get("response_type")) + } + if params.Get("state") != state { + t.Errorf("state = %s, want %s", params.Get("state"), state) + } + if params.Get("code_challenge") != challenge { + t.Errorf("code_challenge = %s, want %s", params.Get("code_challenge"), challenge) + } + if params.Get("code_challenge_method") != "S256" { + t.Errorf("code_challenge_method = %s, want S256", params.Get("code_challenge_method")) + } +} + +func TestOAuthTokenStruct(t *testing.T) { + token := OAuthToken{ + AccessToken: "test-access-token", + RefreshToken: "test-refresh-token", + ExpiresIn: 7200, + TokenType: "Bearer", + } + + if token.AccessToken != "test-access-token" { + t.Errorf("AccessToken = %s, want test-access-token", token.AccessToken) + } + if token.RefreshToken != "test-refresh-token" { + t.Errorf("RefreshToken = %s, want test-refresh-token", token.RefreshToken) + } + if token.ExpiresIn != 7200 { + t.Errorf("ExpiresIn = %d, want 7200", token.ExpiresIn) + } + if token.TokenType != "Bearer" { + t.Errorf("TokenType = %s, want Bearer", token.TokenType) + } +} + +func TestConstants(t *testing.T) { + if redirectURI != "http://127.0.0.1:8088/callback" { + t.Errorf("redirectURI = %s, want http://127.0.0.1:8088/callback", redirectURI) + } + if scope != "docx:document:readonly drive:file:readonly wiki:wiki:readonly" { + t.Errorf("scope = %s, want docx:document:readonly drive:file:readonly wiki:wiki:readonly", scope) + } + if !strings.Contains(authEndpoint, "open.feishu.cn") { + t.Errorf("authEndpoint should contain open.feishu.cn") + } + if !strings.Contains(tokenEndpoint, "open.feishu.cn") { + t.Errorf("tokenEndpoint should contain open.feishu.cn") + } +} + +func TestExchangeCodeForToken(t *testing.T) { + expectedToken := &OAuthToken{ + AccessToken: "test-access-token", + RefreshToken: "test-refresh-token", + ExpiresIn: 7200, + TokenType: "Bearer", + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %s, want application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + } + + resp := struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data OAuthToken `json:"data"` + }{ + Code: 0, + Msg: "success", + Data: *expectedToken, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Override the token endpoint for testing + originalEndpoint := tokenEndpoint + tokenEndpoint = server.URL + defer func() { tokenEndpoint = originalEndpoint }() + + token, err := ExchangeCodeForToken("test-client-id", "test-client-secret", "test-code", "test-verifier") + if err != nil { + t.Fatalf("ExchangeCodeForToken() error = %v", err) + } + if token.AccessToken != expectedToken.AccessToken { + t.Errorf("AccessToken = %s, want %s", token.AccessToken, expectedToken.AccessToken) + } + if token.RefreshToken != expectedToken.RefreshToken { + t.Errorf("RefreshToken = %s, want %s", token.RefreshToken, expectedToken.RefreshToken) + } + if token.ExpiresIn != expectedToken.ExpiresIn { + t.Errorf("ExpiresIn = %d, want %d", token.ExpiresIn, expectedToken.ExpiresIn) + } +} + +func TestExchangeCodeForTokenValidation(t *testing.T) { + _, err := ExchangeCodeForToken("test-client-id", "test-client-secret", "", "test-verifier") + if err == nil { + t.Error("ExchangeCodeForToken() expected error for empty code, got nil") + } + if err.Error() != "code is required" { + t.Errorf("error message = %s, want 'code is required'", err.Error()) + } +} + +func TestRefreshUserToken(t *testing.T) { + expectedToken := &OAuthToken{ + AccessToken: "new-access-token", + RefreshToken: "new-refresh-token", + ExpiresIn: 7200, + TokenType: "Bearer", + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %s, want application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + } + + resp := struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data OAuthToken `json:"data"` + }{ + Code: 0, + Msg: "success", + Data: *expectedToken, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + originalEndpoint := tokenEndpoint + tokenEndpoint = server.URL + defer func() { tokenEndpoint = originalEndpoint }() + + token, err := RefreshUserToken("test-client-id", "test-client-secret", "test-refresh-token") + if err != nil { + t.Fatalf("RefreshUserToken() error = %v", err) + } + if token.AccessToken != expectedToken.AccessToken { + t.Errorf("AccessToken = %s, want %s", token.AccessToken, expectedToken.AccessToken) + } + if token.RefreshToken != expectedToken.RefreshToken { + t.Errorf("RefreshToken = %s, want %s", token.RefreshToken, expectedToken.RefreshToken) + } +} + +func TestRefreshUserTokenValidation(t *testing.T) { + _, err := RefreshUserToken("test-client-id", "test-client-secret", "") + if err == nil { + t.Error("RefreshUserToken() expected error for empty refreshToken, got nil") + } + if err.Error() != "refreshToken is required" { + t.Errorf("error message = %s, want 'refreshToken is required'", err.Error()) + } +} diff --git a/core/client.go b/core/client.go index 7120b9b..3118f94 100644 --- a/core/client.go +++ b/core/client.go @@ -14,7 +14,8 @@ import ( ) type Client struct { - larkClient *lark.Lark + larkClient *lark.Lark + userAccessToken string // stores user access token } func NewClient(appID, appSecret string) *Client { @@ -27,10 +28,32 @@ func NewClient(appID, appSecret string) *Client { } } +// NewClientWithUserToken creates a client that stores user access token for user-identity operations. +// Note: The lark SDK requires user token to be passed per-method via lark.WithUserAccessToken(). +// This constructor stores the token in Client struct for use with method-level options. +func NewClientWithUserToken(appID, appSecret, userToken string) *Client { + return &Client{ + larkClient: lark.New( + lark.WithAppCredential(appID, appSecret), + lark.WithTimeout(60*time.Second), + lark.WithApiMiddleware(lark_rate_limiter.Wait(4, 4)), + ), + userAccessToken: userToken, + } +} + +func (c *Client) userTokenOpts() []lark.MethodOptionFunc { + if c.userAccessToken != "" { + return []lark.MethodOptionFunc{lark.WithUserAccessToken(c.userAccessToken)} + } + return nil +} + func (c *Client) DownloadImage(ctx context.Context, imgToken, outDir string) (string, error) { + opts := c.userTokenOpts() resp, _, err := c.larkClient.Drive.DownloadDriveMedia(ctx, &lark.DownloadDriveMediaReq{ FileToken: imgToken, - }) + }, opts...) if err != nil { return imgToken, err } @@ -53,9 +76,10 @@ func (c *Client) DownloadImage(ctx context.Context, imgToken, outDir string) (st } func (c *Client) DownloadImageRaw(ctx context.Context, imgToken, imgDir string) (string, []byte, error) { + opts := c.userTokenOpts() resp, _, err := c.larkClient.Drive.DownloadDriveMedia(ctx, &lark.DownloadDriveMediaReq{ FileToken: imgToken, - }) + }, opts...) if err != nil { return imgToken, nil, err } @@ -67,9 +91,10 @@ func (c *Client) DownloadImageRaw(ctx context.Context, imgToken, imgDir string) } func (c *Client) GetDocxContent(ctx context.Context, docToken string) (*lark.DocxDocument, []*lark.DocxBlock, error) { + opts := c.userTokenOpts() resp, _, err := c.larkClient.Drive.GetDocxDocument(ctx, &lark.GetDocxDocumentReq{ DocumentID: docToken, - }) + }, opts...) if err != nil { return nil, nil, err } @@ -84,7 +109,7 @@ func (c *Client) GetDocxContent(ctx context.Context, docToken string) (*lark.Doc resp2, _, err := c.larkClient.Drive.GetDocxBlockListOfDocument(ctx, &lark.GetDocxBlockListOfDocumentReq{ DocumentID: docx.DocumentID, PageToken: pageToken, - }) + }, opts...) if err != nil { return docx, nil, err } @@ -98,9 +123,10 @@ func (c *Client) GetDocxContent(ctx context.Context, docToken string) (*lark.Doc } func (c *Client) GetWikiNodeInfo(ctx context.Context, token string) (*lark.GetWikiNodeRespNode, error) { + opts := c.userTokenOpts() resp, _, err := c.larkClient.Drive.GetWikiNode(ctx, &lark.GetWikiNodeReq{ Token: token, - }) + }, opts...) if err != nil { return nil, err } @@ -108,11 +134,12 @@ func (c *Client) GetWikiNodeInfo(ctx context.Context, token string) (*lark.GetWi } func (c *Client) GetDriveFolderFileList(ctx context.Context, pageToken *string, folderToken *string) ([]*lark.GetDriveFileListRespFile, error) { + opts := c.userTokenOpts() resp, _, err := c.larkClient.Drive.GetDriveFileList(ctx, &lark.GetDriveFileListReq{ PageSize: nil, PageToken: pageToken, FolderToken: folderToken, - }) + }, opts...) if err != nil { return nil, err } @@ -122,7 +149,7 @@ func (c *Client) GetDriveFolderFileList(ctx context.Context, pageToken *string, PageSize: nil, PageToken: &resp.NextPageToken, FolderToken: folderToken, - }) + }, opts...) if err != nil { return nil, err } @@ -132,9 +159,10 @@ func (c *Client) GetDriveFolderFileList(ctx context.Context, pageToken *string, } func (c *Client) GetWikiName(ctx context.Context, spaceID string) (string, error) { + opts := c.userTokenOpts() resp, _, err := c.larkClient.Drive.GetWikiSpace(ctx, &lark.GetWikiSpaceReq{ SpaceID: spaceID, - }) + }, opts...) if err != nil { return "", err @@ -144,12 +172,13 @@ func (c *Client) GetWikiName(ctx context.Context, spaceID string) (string, error } func (c *Client) GetWikiNodeList(ctx context.Context, spaceID string, parentNodeToken *string) ([]*lark.GetWikiNodeListRespItem, error) { + opts := c.userTokenOpts() resp, _, err := c.larkClient.Drive.GetWikiNodeList(ctx, &lark.GetWikiNodeListReq{ SpaceID: spaceID, PageSize: nil, PageToken: nil, ParentNodeToken: parentNodeToken, - }) + }, opts...) if err != nil { return nil, err @@ -165,7 +194,7 @@ func (c *Client) GetWikiNodeList(ctx context.Context, spaceID string, parentNode PageSize: nil, PageToken: &resp.PageToken, ParentNodeToken: parentNodeToken, - }) + }, opts...) if err != nil { return nil, err diff --git a/core/config.go b/core/config.go index c0aea08..ac1a109 100644 --- a/core/config.go +++ b/core/config.go @@ -13,8 +13,11 @@ type Config struct { } type FeishuConfig struct { - AppId string `json:"app_id"` - AppSecret string `json:"app_secret"` + AppId string `json:"app_id"` + AppSecret string `json:"app_secret"` + UserAccessToken string `json:"user_access_token"` + RefreshToken string `json:"refresh_token"` + TokenExpireTime int64 `json:"token_expire_time"` } type OutputConfig struct { @@ -27,8 +30,11 @@ type OutputConfig struct { func NewConfig(appId, appSecret string) *Config { return &Config{ Feishu: FeishuConfig{ - AppId: appId, - AppSecret: appSecret, + AppId: appId, + AppSecret: appSecret, + UserAccessToken: "", + RefreshToken: "", + TokenExpireTime: 0, }, Output: OutputConfig{ ImageDir: "static", diff --git a/web/download.go b/web/download.go index deb9631..abfa0e5 100644 --- a/web/download.go +++ b/web/download.go @@ -8,7 +8,6 @@ import ( "log" "net/http" "net/url" - "os" "strings" "github.com/88250/lute" @@ -29,15 +28,34 @@ func downloadHandler(c *gin.Context) { docType, docToken, err := utils.ValidateDocumentURL(feishu_docx_url) fmt.Println("Captured document token:", docToken) - // Create client with context + // Load config from file ctx := context.Background() - config := core.NewConfig( - os.Getenv("FEISHU_APP_ID"), - os.Getenv("FEISHU_APP_SECRET"), - ) - client := core.NewClient( - config.Feishu.AppId, config.Feishu.AppSecret, - ) + configPath, err := core.GetConfigFilePath() + if err != nil { + c.String(http.StatusInternalServerError, "Failed to get config path") + return + } + config, err := core.ReadConfigFromFile(configPath) + if err != nil { + c.String(http.StatusInternalServerError, "Failed to read config") + return + } + + // Create client with user token if available + var client *core.Client + if config.Feishu.UserAccessToken != "" { + client = core.NewClientWithUserToken( + config.Feishu.AppId, + config.Feishu.AppSecret, + config.Feishu.UserAccessToken, + ) + fmt.Println("Using user identity for download") + } else { + client = core.NewClient( + config.Feishu.AppId, config.Feishu.AppSecret, + ) + fmt.Println("Using app identity for download") + } // Process the download parser := core.NewParser(config.Output)