From 1037e740a2e5ed8559f7118ea8441f7101ffac67 Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Thu, 12 Mar 2026 14:57:59 +0100 Subject: [PATCH 1/5] :recycle: :art: Refacto transfer, drop some duplicate code --- cmd/transfer.go | 367 +++++++++++++++++++++--------------------------- 1 file changed, 159 insertions(+), 208 deletions(-) diff --git a/cmd/transfer.go b/cmd/transfer.go index ffd4891..2897fc3 100644 --- a/cmd/transfer.go +++ b/cmd/transfer.go @@ -49,18 +49,12 @@ var transferLsCmd = &cobra.Command{ listType = "received" } - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("loading config: %w", err) - } - ctx := context.Background() - tok, err := mustGetToken(ctx, cfg) + _, client, err := newAPIClient(ctx) if err != nil { return err } - client := api.New(cfg.API.BaseURL, cliUserAgent(), tok, insecure, debug) result, err := client.ListTransfers(ctx, listType, 1) if err != nil { return fmt.Errorf("listing transfers: %w", err) @@ -103,51 +97,16 @@ var transferInfoCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { shareID := args[0] - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("loading config: %w", err) - } - ctx := context.Background() - tok, err := mustGetToken(ctx, cfg) + cfg, client, err := newAPIClient(ctx) if err != nil { return err } - client := api.New(cfg.API.BaseURL, cliUserAgent(), tok, insecure, debug) - - // Fetch transfer details and user key in parallel. - type detailsResult struct { - v *api.TransferDetails - err error - } - type keyResult struct { - v *api.UserKey - err error - } - detailsCh := make(chan detailsResult, 1) - keyCh := make(chan keyResult, 1) - - go func() { - v, err := client.GetTransferDetails(ctx, shareID) - detailsCh <- detailsResult{v, err} - }() - go func() { - v, err := client.GetActiveKey(ctx) - keyCh <- keyResult{v, err} - }() - - dr := <-detailsCh - if dr.err != nil { - return fmt.Errorf("fetching transfer: %w", dr.err) - } - details := dr.v - - kr := <-keyCh - if kr.err != nil { - return fmt.Errorf("fetching encryption key: %w", kr.err) + details, userKey, err := fetchDetailsAndKey(ctx, client, shareID) + if err != nil { + return err } - userKey := kr.v // Display basic metadata (no crypto required). fmt.Printf("ID: %s\n", ptrOr(details.ID, "—")) @@ -185,39 +144,9 @@ var transferInfoCmd = &cobra.Command{ return fmt.Errorf("no active encryption key found — set up your key in the web interface first") } - // Try the kernel keyring cache first (skippable via config). - var identityStr string - if cfg.Keyring.Enabled { - var err error - identityStr, err = keyring.Load() - if err != nil { - fmt.Fprintf(os.Stderr, "warning: keyring load: %v\n", err) - } - } - - if identityStr == "" { - passphrase, err := readKeyPassphrase() - if err != nil { - return err - } - - // Decrypt user's AGE private key (scrypt). - identityStr, err = crypto.DecryptToStringWithPassphrase(userKey.PrivateKeyEnc, passphrase) - if err != nil { - return fmt.Errorf("wrong key passphrase") - } - - // Cache in the kernel keyring if enabled. - if cfg.Keyring.Enabled { - if err := keyring.Store(identityStr, cfg.Keyring.TTL); err != nil { - fmt.Fprintf(os.Stderr, "warning: keyring store: %v\n", err) - } - } - } - - identity, err := crypto.ParseIdentity(identityStr) + identity, err := resolveUserIdentity(cfg, userKey) if err != nil { - return fmt.Errorf("parsing AGE identity: %w", err) + return err } // Decrypt session private key (X25519). @@ -318,6 +247,119 @@ func mustGetToken(ctx context.Context, cfg *config.Config) (oauth2.TokenSource, return auth.NewRefreshingTokenSource(tok, *oidcCfg, httpClient, persist), nil } +// newAPIClient loads config, obtains a valid token, and returns both the config +// and an authenticated API client ready to use. +func newAPIClient(ctx context.Context) (*config.Config, *api.Client, error) { + cfg, err := config.Load() + if err != nil { + return nil, nil, fmt.Errorf("loading config: %w", err) + } + tok, err := mustGetToken(ctx, cfg) + if err != nil { + return nil, nil, err + } + + return cfg, api.New(cfg.API.BaseURL, cliUserAgent(), tok, insecure, debug), nil +} + +// fetchDetailsAndKey fetches transfer details and the active user key concurrently. +func fetchDetailsAndKey( + ctx context.Context, + client *api.Client, + shareID string, +) (*api.TransferDetails, *api.UserKey, error) { + type detailsResult struct { + v *api.TransferDetails + err error + } + type keyResult struct { + v *api.UserKey + err error + } + detailsCh := make(chan detailsResult, 1) + keyCh := make(chan keyResult, 1) + go func() { v, err := client.GetTransferDetails(ctx, shareID); detailsCh <- detailsResult{v, err} }() + go func() { v, err := client.GetActiveKey(ctx); keyCh <- keyResult{v, err} }() + + dr := <-detailsCh + if dr.err != nil { + return nil, nil, fmt.Errorf("fetching transfer: %w", dr.err) + } + kr := <-keyCh + if kr.err != nil { + return nil, nil, fmt.Errorf("fetching encryption key: %w", kr.err) + } + + return dr.v, kr.v, nil +} + +// resolveUserIdentity decrypts the user's AGE private key using the passphrase, +// reading from the keyring cache first when enabled. +func resolveUserIdentity(cfg *config.Config, userKey *api.UserKey) (*age.HybridIdentity, error) { + var identityStr string + if cfg.Keyring.Enabled { + var err error + identityStr, err = keyring.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "warning: keyring load: %v\n", err) + } + } + + if identityStr == "" { + passphrase, err := readKeyPassphrase() + if err != nil { + return nil, err + } + identityStr, err = crypto.DecryptToStringWithPassphrase(userKey.PrivateKeyEnc, passphrase) + if err != nil { + return nil, fmt.Errorf("wrong key passphrase") + } + if cfg.Keyring.Enabled { + if err := keyring.Store(identityStr, cfg.Keyring.TTL); err != nil { + fmt.Fprintf(os.Stderr, "warning: keyring store: %v\n", err) + } + } + } + + identity, err := crypto.ParseIdentity(identityStr) + if err != nil { + return nil, fmt.Errorf("parsing AGE identity: %w", err) + } + + return identity, nil +} + +// confirmFileList prints a summary of files and prompts the user to proceed. +// extras contains additional pre-formatted info lines printed after the file summary +// (e.g. " Expires: in 1h", " Destination: transfer-xyz/"). +// Returns true if the user confirms. +func confirmFileList(names []string, sizes []int64, totalSize int64, extras []string) bool { + const lineWidth = 44 + fmt.Fprintln(os.Stderr) + for i, name := range names { + if len(name) > lineWidth-10 { + name = name[:lineWidth-13] + "…" + } + fmt.Fprintf(os.Stderr, " %-*s %s\n", lineWidth-10, name, formatSize(sizes[i])) + } + fmt.Fprintf(os.Stderr, " %s\n", strings.Repeat("─", lineWidth)) + noun := "file" + if len(names) > 1 { + noun = "files" + } + fmt.Fprintf(os.Stderr, " %-*s %s\n", lineWidth-10, fmt.Sprintf("%d %s", len(names), noun), formatSize(totalSize)) + fmt.Fprintln(os.Stderr) + for _, extra := range extras { + fmt.Fprintln(os.Stderr, extra) + } + fmt.Fprintln(os.Stderr) + fmt.Fprint(os.Stderr, "Proceed? [y/N] ") + answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') + fmt.Fprintln(os.Stderr) + + return strings.ToLower(strings.TrimSpace(answer)) == "y" +} + // printBoxedMessage prints a decrypted message with a left vertical bar and padding, // so multi-line messages are clearly delimited and easy to read. func printBoxedMessage(msg string) { @@ -414,53 +456,33 @@ var transferCreateCmd = &cobra.Command{ // Confirmation prompt (skip with --yes / -y). if !yes { - const lineWidth = 44 - fmt.Fprintln(os.Stderr) - for _, e := range entries { - name := e.name - if len(name) > lineWidth-10 { - name = name[:lineWidth-13] + "…" - } - fmt.Fprintf(os.Stderr, " %-*s %s\n", lineWidth-10, name, formatSize(e.size)) - } - fmt.Fprintf(os.Stderr, " %s\n", strings.Repeat("─", lineWidth)) - noun := "file" - if len(entries) > 1 { - noun = "files" + names := make([]string, len(entries)) + sizes := make([]int64, len(entries)) + for i, e := range entries { + names[i] = e.name + sizes[i] = e.size } - fmt.Fprintf(os.Stderr, " %-*s %s\n", lineWidth-10, fmt.Sprintf("%d %s", len(entries), noun), formatSize(totalSize)) - fmt.Fprintln(os.Stderr) + var extras []string if title != "" { - fmt.Fprintf(os.Stderr, " Title: %s\n", title) + extras = append(extras, fmt.Sprintf(" Title: %s", title)) } if len(toEmails) > 0 { - fmt.Fprintf(os.Stderr, " To: %s\n", strings.Join(toEmails, ", ")) + extras = append(extras, fmt.Sprintf(" To: %s", strings.Join(toEmails, ", "))) } - fmt.Fprintf(os.Stderr, " Expires: %s\n", formatExpiry(expire)) - fmt.Fprintln(os.Stderr) - fmt.Fprint(os.Stderr, "Proceed? [y/N] ") - answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') - fmt.Fprintln(os.Stderr) - if strings.ToLower(strings.TrimSpace(answer)) != "y" { + extras = append(extras, fmt.Sprintf(" Expires: %s", formatExpiry(expire))) + if !confirmFileList(names, sizes, totalSize, extras) { fmt.Fprintln(os.Stderr, "Aborted.") return nil } } - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("loading config: %w", err) - } - ctx := context.Background() - tok, err := mustGetToken(ctx, cfg) + _, client, err := newAPIClient(ctx) if err != nil { return err } - client := api.New(cfg.API.BaseURL, cliUserAgent(), tok, insecure, debug) - var titlePtr *string if title != "" { titlePtr = &title @@ -798,18 +820,12 @@ var transferDisableCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { shareID := args[0] - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("loading config: %w", err) - } - ctx := context.Background() - tok, err := mustGetToken(ctx, cfg) + _, client, err := newAPIClient(ctx) if err != nil { return err } - client := api.New(cfg.API.BaseURL, cliUserAgent(), tok, insecure, debug) if err := client.DisableTransfer(ctx, shareID); err != nil { return fmt.Errorf("disabling transfer: %w", err) } @@ -827,18 +843,12 @@ var transferEnableCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { shareID := args[0] - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("loading config: %w", err) - } - ctx := context.Background() - tok, err := mustGetToken(ctx, cfg) + _, client, err := newAPIClient(ctx) if err != nil { return err } - client := api.New(cfg.API.BaseURL, cliUserAgent(), tok, insecure, debug) if err := client.EnableTransfer(ctx, shareID); err != nil { return fmt.Errorf("enabling transfer: %w", err) } @@ -858,81 +868,38 @@ var transferDownloadCmd = &cobra.Command{ outputDir, _ := cmd.Flags().GetString("output") yes, _ := cmd.Flags().GetBool("yes") - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("loading config: %w", err) - } - ctx := context.Background() - tok, err := mustGetToken(ctx, cfg) + cfg, client, err := newAPIClient(ctx) if err != nil { return err } - client := api.New(cfg.API.BaseURL, cliUserAgent(), tok, insecure, debug) - - // Fetch transfer details and user key in parallel. - type detailsResult struct { - v *api.TransferDetails - err error - } - type keyResult struct { - v *api.UserKey - err error - } - detailsCh := make(chan detailsResult, 1) - keyCh := make(chan keyResult, 1) - go func() { v, err := client.GetTransferDetails(ctx, shareID); detailsCh <- detailsResult{v, err} }() - go func() { v, err := client.GetActiveKey(ctx); keyCh <- keyResult{v, err} }() - - dr := <-detailsCh - if dr.err != nil { - return fmt.Errorf("fetching transfer: %w", dr.err) - } - details := dr.v - - kr := <-keyCh - if kr.err != nil { - return fmt.Errorf("fetching encryption key: %w", kr.err) + details, userKey, err := fetchDetailsAndKey(ctx, client, shareID) + if err != nil { + return err } - userKey := kr.v if details.SessionPrivateKeyEnc == nil && details.SessionPrivateKeyEncForPassphrase == nil { return fmt.Errorf("transfer not yet completed — no encrypted content available") } - // Resolve session private key: try user key path first, then transfer passphrase. - var identityStr string + // Resolve session identity: try user key path first, then transfer passphrase. + var sessionIdentity *age.HybridIdentity if userKey != nil && details.SessionPrivateKeyEnc != nil { - // Try keyring cache first. - if cfg.Keyring.Enabled { - identityStr, _ = keyring.Load() - } - if identityStr == "" { - passphrase, err := readKeyPassphrase() - if err != nil { - return err - } - identityStr, err = crypto.DecryptToStringWithPassphrase(userKey.PrivateKeyEnc, passphrase) - if err != nil { - return fmt.Errorf("wrong key passphrase") - } - if cfg.Keyring.Enabled { - if err := keyring.Store(identityStr, cfg.Keyring.TTL); err != nil { - fmt.Fprintf(os.Stderr, "warning: keyring store: %v\n", err) - } - } - } - identity, err := crypto.ParseIdentity(identityStr) + userIdentity, err := resolveUserIdentity(cfg, userKey) if err != nil { - return fmt.Errorf("parsing identity: %w", err) + return err } - sessionPrivKey, err := crypto.DecryptToString(*details.SessionPrivateKeyEnc, identity) + sessionPrivKey, err := crypto.DecryptToString(*details.SessionPrivateKeyEnc, userIdentity) if err != nil { return fmt.Errorf("decrypting session key (key mismatch?): %w", err) } - identityStr = sessionPrivKey + si, err := crypto.ParseIdentity(sessionPrivKey) + if err != nil { + return fmt.Errorf("parsing session identity: %w", err) + } + sessionIdentity = si } else { // Ephemeral path: use transfer passphrase. if details.EphemeralPrivateKeyEnc == nil || details.SessionPrivateKeyEncForPassphrase == nil { @@ -956,12 +923,11 @@ var transferDownloadCmd = &cobra.Command{ if err != nil { return fmt.Errorf("decrypting session key: %w", err) } - identityStr = sessionPrivKey - } - - sessionIdentity, err := crypto.ParseIdentity(identityStr) - if err != nil { - return fmt.Errorf("parsing session identity: %w", err) + si, err := crypto.ParseIdentity(sessionPrivKey) + if err != nil { + return fmt.Errorf("parsing session identity: %w", err) + } + sessionIdentity = si } // Fetch all files (paginate). @@ -1013,29 +979,14 @@ var transferDownloadCmd = &cobra.Command{ // Confirmation prompt (skip with --yes / -y). if !yes { - const lineWidth = 44 - fmt.Fprintln(os.Stderr) - for _, f := range decFiles { - name := f.name - if len(name) > lineWidth-10 { - name = name[:lineWidth-13] + "…" - } - fmt.Fprintf(os.Stderr, " %-*s %s\n", lineWidth-10, name, formatSize(f.OriginalSize)) - } - fmt.Fprintf(os.Stderr, " %s\n", strings.Repeat("─", lineWidth)) - noun := "file" - if len(decFiles) > 1 { - noun = "files" + names := make([]string, len(decFiles)) + sizes := make([]int64, len(decFiles)) + for i, f := range decFiles { + names[i] = f.name + sizes[i] = f.OriginalSize } - fmt.Fprintf(os.Stderr, " %-*s %s\n", lineWidth-10, - fmt.Sprintf("%d %s", len(decFiles), noun), formatSize(totalSize)) - fmt.Fprintln(os.Stderr) - fmt.Fprintf(os.Stderr, " Destination: %s/\n", outputDir) - fmt.Fprintln(os.Stderr) - fmt.Fprint(os.Stderr, "Proceed? [y/N] ") - answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') - fmt.Fprintln(os.Stderr) - if strings.ToLower(strings.TrimSpace(answer)) != "y" { + extras := []string{fmt.Sprintf(" Destination: %s/", outputDir)} + if !confirmFileList(names, sizes, totalSize, extras) { fmt.Fprintln(os.Stderr, "Aborted.") return nil From d0d00626950a7d85daea00566510e8e9ae292247 Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Thu, 12 Mar 2026 15:19:25 +0100 Subject: [PATCH 2/5] :hammer: Do not check lint "errcheck" on print functions --- .golangci.yml | 6 ++++++ cmd/root.go | 1 - cmd/transfer.go | 8 ++++---- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 59ee720..a5b6dca 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -10,6 +10,12 @@ linters: - misspell # detect commonly misspelled words - nlreturn # check for missing return statements in functions - whitespace # check for whitespace issues + settings: + errcheck: + exclude-functions: + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln issues: fix: false # use `golangci-lint run --fix` to automatically fix some of the issues diff --git a/cmd/root.go b/cmd/root.go index 46c135c..60f140c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,7 +11,6 @@ import ( "github.com/spf13/viper" ) - var ( cfgFile string debug bool diff --git a/cmd/transfer.go b/cmd/transfer.go index 2897fc3..589657d 100644 --- a/cmd/transfer.go +++ b/cmd/transfer.go @@ -67,13 +67,13 @@ var transferLsCmd = &cobra.Command{ } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "ID\tSTATUS\tTITLE\tCREATED") //nolint:errcheck + fmt.Fprintln(w, "ID\tSTATUS\tTITLE\tCREATED") for _, t := range result.Items { title := "" if t.Title != nil { title = *t.Title } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", //nolint:errcheck + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", t.ID, t.Status, title, @@ -181,13 +181,13 @@ var transferInfoCmd = &cobra.Command{ fmt.Printf("\nFiles (%d):\n", filePage.Total) w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, " NAME\tSIZE") //nolint:errcheck + fmt.Fprintln(w, " NAME\tSIZE") for _, f := range filePage.Items { name, err := crypto.DecryptToString(f.NameEnc, sessionIdentity) if err != nil { name = "(encrypted)" } - fmt.Fprintf(w, " %s\t%s\n", name, formatSize(f.OriginalSize)) //nolint:errcheck + fmt.Fprintf(w, " %s\t%s\n", name, formatSize(f.OriginalSize)) } _ = w.Flush() From e8574eb59169f0f0409d769fd6baa3fefa59999a Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Thu, 12 Mar 2026 15:33:55 +0100 Subject: [PATCH 3/5] :memo: Force Claude to use lint --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index f522120..c8f4fa1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -214,3 +214,4 @@ The CLI exposes everything as `transfer`. Do not rename backend routes. - `.retyc/` in CWD is gitignored (dev token/config should not be committed) - `SilenceUsage: true` + `SilenceErrors: true` on rootCmd — errors printed once by `RunE`, not by cobra - No auto-commit +- Always perform linting with `make lint-fix` after editing code (uses `golangci-lint` with `--fix` to auto-apply simple fixes) From 6e227b69f1611f38106189093ad56908a758cb69 Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Thu, 12 Mar 2026 15:34:53 +0100 Subject: [PATCH 4/5] :art: Use Cobra context in transfer commands --- cmd/transfer.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/transfer.go b/cmd/transfer.go index 589657d..bb2f59b 100644 --- a/cmd/transfer.go +++ b/cmd/transfer.go @@ -49,7 +49,7 @@ var transferLsCmd = &cobra.Command{ listType = "received" } - ctx := context.Background() + ctx := cmd.Context() _, client, err := newAPIClient(ctx) if err != nil { return err @@ -97,7 +97,7 @@ var transferInfoCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { shareID := args[0] - ctx := context.Background() + ctx := cmd.Context() cfg, client, err := newAPIClient(ctx) if err != nil { return err @@ -477,7 +477,7 @@ var transferCreateCmd = &cobra.Command{ } } - ctx := context.Background() + ctx := cmd.Context() _, client, err := newAPIClient(ctx) if err != nil { return err @@ -820,7 +820,7 @@ var transferDisableCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { shareID := args[0] - ctx := context.Background() + ctx := cmd.Context() _, client, err := newAPIClient(ctx) if err != nil { return err @@ -843,7 +843,7 @@ var transferEnableCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { shareID := args[0] - ctx := context.Background() + ctx := cmd.Context() _, client, err := newAPIClient(ctx) if err != nil { return err @@ -868,7 +868,7 @@ var transferDownloadCmd = &cobra.Command{ outputDir, _ := cmd.Flags().GetString("output") yes, _ := cmd.Flags().GetBool("yes") - ctx := context.Background() + ctx := cmd.Context() cfg, client, err := newAPIClient(ctx) if err != nil { return err From 9b08d617bc7ded6cb1566b991e0d84eef1faecbc Mon Sep 17 00:00:00 2001 From: Emilien Mantel Date: Thu, 12 Mar 2026 15:36:46 +0100 Subject: [PATCH 5/5] :bug: Fix UTF-8 truncation in transfer file list --- cmd/transfer.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/transfer.go b/cmd/transfer.go index bb2f59b..ed9b7bc 100644 --- a/cmd/transfer.go +++ b/cmd/transfer.go @@ -337,8 +337,9 @@ func confirmFileList(names []string, sizes []int64, totalSize int64, extras []st const lineWidth = 44 fmt.Fprintln(os.Stderr) for i, name := range names { - if len(name) > lineWidth-10 { - name = name[:lineWidth-13] + "…" + runes := []rune(name) + if len(runes) > lineWidth-10 { + name = string(runes[:lineWidth-13]) + "…" } fmt.Fprintf(os.Stderr, " %-*s %s\n", lineWidth-10, name, formatSize(sizes[i])) }