Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./...
Expand Down
78 changes: 68 additions & 10 deletions cmd/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"sync"
"time"

"github.com/88250/lute"
"github.com/Wsine/feishu2md/core"
Expand All @@ -25,6 +26,47 @@ 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 refreshes token if expired
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
}

// Auto-refresh token if expired but refresh_token is available
if config.Feishu.UserAccessToken != "" && isTokenExpired(config) && config.Feishu.RefreshToken != "" {
fmt.Println("User token expired, attempting auto-refresh...")
token, err := core.RefreshUserToken(config.Feishu.AppId, config.Feishu.AppSecret, config.Feishu.RefreshToken)
if err != nil {
fmt.Printf("Token refresh failed: %v\nFalling back to app identity. Run 'feishu2md login' to re-authenticate.\n", err)
config.Feishu.UserAccessToken = ""
} else {
config.Feishu.UserAccessToken = token.AccessToken
config.Feishu.RefreshToken = token.RefreshToken
config.Feishu.TokenExpireTime = time.Now().Unix() + int64(token.ExpiresIn)
if err := config.WriteConfig2File(configPath); err != nil {
fmt.Printf("Warning: failed to save refreshed token: %v\n", err)
} else {
fmt.Println("Token refreshed successfully.")
}
}
}

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)
Expand Down Expand Up @@ -59,15 +101,23 @@ func downloadDocument(ctx context.Context, client *core.Client, url string, opts
markdown := parser.ParseDocxContent(docx, blocks)

if !dlConfig.Output.SkipImgDownload {
imgTotal := len(parser.ImgTokens)
imgFailed := 0
for _, imgToken := range parser.ImgTokens {
localLink, err := client.DownloadImage(
ctx, imgToken, filepath.Join(opts.outputDir, dlConfig.Output.ImageDir),
)
if err != nil {
return err
imgFailed++
fmt.Printf("Warning: failed to download image [%d/%d] token=%s\n error: %v\n view: https://open.feishu.cn/open-apis/drive/v1/medias/%s/download\n",
imgFailed, imgTotal, imgToken, err, imgToken)
continue
}
markdown = strings.Replace(markdown, imgToken, localLink, 1)
}
if imgFailed > 0 {
fmt.Printf("Image download summary: %d/%d succeeded, %d failed\n", imgTotal-imgFailed, imgTotal, imgFailed)
}
}

// Format the markdown document
Expand Down Expand Up @@ -246,20 +296,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 {
Expand Down
203 changes: 203 additions & 0 deletions cmd/login.go
Original file line number Diff line number Diff line change
@@ -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 <id> --appSecret <secret>' 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(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Login Successful</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }
.container { text-align: center; padding: 40px; background: white; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #52c41a; margin-bottom: 10px; }
p { color: #666; }
</style>
</head>
<body>
<div class="container">
<h1>Login Successful!</h1>
<p>You can now close this window and use feishu2md commands.</p>
</div>
</body>
</html>`))
})

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
}
15 changes: 15 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
},
},
},
}

Expand Down
Loading