Skip to content
Merged
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.2
charm.land/lipgloss/v2 v2.0.0
github.com/basecamp/basecamp-sdk/go v0.4.0
github.com/basecamp/basecamp-sdk/go v0.6.0
github.com/basecamp/cli v0.1.1
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/glamour v1.0.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/basecamp/basecamp-sdk/go v0.4.0 h1:O/Sywalv97zHqaHboxuZo7JmMfwQvU3uuWEOMdZVChU=
github.com/basecamp/basecamp-sdk/go v0.4.0/go.mod h1:g05DM58QkUm4/mvBAvRiugPw+F4trliuGkRGg8y+Th4=
github.com/basecamp/basecamp-sdk/go v0.6.0 h1:LnyW0rFI5/1vmGpmFnogE+8ms/kueCwg5U/mSAuLJYw=
github.com/basecamp/basecamp-sdk/go v0.6.0/go.mod h1:g05DM58QkUm4/mvBAvRiugPw+F4trliuGkRGg8y+Th4=
github.com/basecamp/cli v0.1.1 h1:FAF3M09xo1m7gJJXf38glCkT50ZUuvz+31f+c3R3zcc=
github.com/basecamp/cli v0.1.1/go.mod h1:NTHe+keCTGI2qM5sMXdkUN0QgU3zGbwnBxcmg8vD5QU=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
Expand Down
10 changes: 9 additions & 1 deletion internal/appctx/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ func NewApp(cfg *config.Config) *App {
// Note: resilience.GatingHooks implements basecamp.GatingHooks, while CLIHooks implements basecamp.Hooks
hooks := basecamp.NewChainHooks(gatingHooks, cliHooks)

// Create a shared transport for both the SDK and manual HTTP requests.
// This ensures connection pooling, proxy settings, and custom CA/mTLS
// are consistent across all HTTP calls.
transport := http.DefaultTransport

// Create SDK client with auth adapter and chained hooks
// Note: AccountID is NOT set here - use app.Account() for account-scoped operations
sdkCfg := &basecamp.Config{
Expand All @@ -114,7 +119,10 @@ func NewApp(cfg *config.Config) *App {
CacheDir: cfg.CacheDir,
CacheEnabled: cfg.CacheEnabled,
}
sdkClient := basecamp.NewClient(sdkCfg, &authAdapter{mgr: authMgr}, basecamp.WithHooks(hooks))
sdkClient := basecamp.NewClient(sdkCfg, &authAdapter{mgr: authMgr},
basecamp.WithHooks(hooks),
basecamp.WithTransport(transport),
)

// Create name resolver using SDK client and account ID
nameResolver := names.NewResolver(sdkClient, authMgr, cfg.AccountID)
Expand Down
134 changes: 99 additions & 35 deletions internal/commands/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package commands
import (
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
Expand Down Expand Up @@ -1351,10 +1352,14 @@ func newFilesDownloadCmd(project *string) *cobra.Command {
Short: "Download an uploaded file",
Long: `Download an uploaded file to the local filesystem.

You can pass either an upload ID or a Basecamp URL:
You can pass either an upload ID, a Basecamp URL, or a storage URL:
basecamp files download 789 --in my-project
basecamp files download https://3.basecamp.com/123/buckets/456/uploads/789
basecamp files download 789 --out ./downloads --in my-project`,
basecamp files download "https://storage.3.basecamp.com/123/blobs/abc/download/report.pdf"
basecamp files download 789 --out ./downloads --in my-project

Storage URLs (from inline attachments in rich text) are downloaded directly
via the API. No --in flag is needed for storage URLs.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
Expand All @@ -1363,7 +1368,37 @@ You can pass either an upload ID or a Basecamp URL:
return err
}

// Extract ID and project from URL if provided
// Storage URL path: download via SDK (handles URL rewriting, auth, redirects)
if isStorageURL(args[0]) {
result, err := app.Account().DownloadURL(cmd.Context(), args[0])
if err != nil {
return convertSDKError(err)
}
defer result.Body.Close()

filename, outputPath, bytesWritten, err := writeDownloadToFile(result, outDir, result.Filename)
if err != nil {
return err
}

downloadResult := struct {
URL string `json:"url"`
Filename string `json:"filename"`
Path string `json:"path"`
ByteSize int64 `json:"byte_size"`
}{
URL: args[0],
Filename: filename,
Path: outputPath,
ByteSize: bytesWritten,
}

return app.OK(downloadResult,
output.WithSummary(fmt.Sprintf("Downloaded %s (%d bytes)", filename, bytesWritten)),
)
}

// Upload ID path: two-step download via SDK
uploadIDStr, urlProjectID := extractWithProject(args[0])

uploadID, err := strconv.ParseInt(uploadIDStr, 10, 64)
Expand Down Expand Up @@ -1401,39 +1436,10 @@ You can pass either an upload ID or a Basecamp URL:
}
defer result.Body.Close()

// Determine output directory
outputDir := outDir
if outputDir == "" {
outputDir = "."
}

// Create output file path with sanitized filename to prevent path traversal
filename := result.Filename
if filename == "" {
filename = fmt.Sprintf("upload-%d", uploadID)
}
// Sanitize filename: use only the base name to prevent path traversal
filename = filepath.Base(filename)
outputPath := filepath.Join(outputDir, filename)

// Verify the resolved path is within outputDir to prevent traversal attacks
absOutputDir, _ := filepath.Abs(outputDir)
absOutputPath, _ := filepath.Abs(outputPath)
if !strings.HasPrefix(absOutputPath, absOutputDir+string(filepath.Separator)) && absOutputPath != absOutputDir {
return output.ErrUsage("Invalid filename: path traversal detected")
}

// Create output file
outFile, err := createFile(outputPath)
fallback := fmt.Sprintf("upload-%d", uploadID)
filename, outputPath, bytesWritten, err := writeDownloadToFile(result, outDir, fallback)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()

// Copy content to file
bytesWritten, err := copyFileContent(outFile, result.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
return err
}

// Build result for output
Expand Down Expand Up @@ -1486,6 +1492,64 @@ func copyFileContent(dst *os.File, src io.Reader) (int64, error) {
return io.Copy(dst, src)
}

// writeDownloadToFile writes a DownloadResult to a file in outDir, using
// result.Filename if available, otherwise fallbackName. Returns the sanitized
// filename, output path, and bytes written.
func writeDownloadToFile(result *basecamp.DownloadResult, outDir, fallbackName string) (filename, outputPath string, written int64, err error) {
dir := outDir
if dir == "" {
dir = "."
}

filename = result.Filename
if filename == "" {
filename = fallbackName
}
filename = filepath.Base(filename)
if filename == "." || filename == "" {
filename = "download"
}
outputPath = filepath.Join(dir, filename)

// Verify the resolved path is within dir to prevent traversal attacks
absDir, err := filepath.Abs(dir)
if err != nil {
return "", "", 0, fmt.Errorf("failed to resolve output directory: %w", err)
}
absPath, err := filepath.Abs(outputPath)
if err != nil {
return "", "", 0, fmt.Errorf("failed to resolve output path: %w", err)
}
if !strings.HasPrefix(absPath, absDir+string(filepath.Separator)) {
return "", "", 0, output.ErrUsage("Invalid filename: path traversal detected")
}

outFile, err := createFile(outputPath)
if err != nil {
return "", "", 0, fmt.Errorf("failed to create output file: %w", err)
}
defer outFile.Close()

written, err = copyFileContent(outFile, result.Body)
if err != nil {
return "", "", 0, fmt.Errorf("failed to write file: %w", err)
}
return filename, outputPath, written, nil
}

// isStorageURL returns true if the argument looks like a Basecamp storage blob URL.
func isStorageURL(arg string) bool {
u, err := url.Parse(arg)
if err != nil {
return false
}
return u.Scheme == "https" &&
strings.HasSuffix(u.Hostname(), ".basecamp.com") &&
strings.HasPrefix(u.Hostname(), "storage.") &&
strings.Contains(u.Path, "/blobs/") &&
strings.Contains(u.Path, "/download/")
}

// humanSize formats bytes as a human-readable string (e.g., "9.1mb").
func humanSize(bytes int64) string {
switch {
Expand Down
26 changes: 26 additions & 0 deletions internal/commands/files_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@ import (
"github.com/basecamp/basecamp-cli/internal/output"
)

func TestIsStorageURL(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"https://storage.3.basecamp.com/123/blobs/abc/download/file.eml", true},
{"https://storage.3.basecamp.com/99/blobs/def-ghi/download/My%20Doc.pdf", true},
{"https://3.basecamp.com/123/buckets/456/uploads/789", false},
{"789", false},
{"", false},
{"https://storage.3.basecamp.com/123/blobs/abc", false}, // no /download/
{"https://evil.com/blobs/abc/download/file.eml", false}, // wrong host
{"https://storage.3.basecamp.com/123/uploads/789", false}, // no /blobs/
{"https://storage.evil.basecamp.com.evil.com/blobs/x/download/y", false}, // wrong TLD
{"http://storage.3.basecamp.com/123/blobs/abc/download/file.eml", false}, // http not allowed
{"ftp://storage.3.basecamp.com/123/blobs/abc/download/file.eml", false}, // wrong scheme
{"storage.3.basecamp.com/123/blobs/abc/download/file.eml", false}, // no scheme
}

for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
assert.Equal(t, tt.want, isStorageURL(tt.input))
})
}
}

// TestDocsCreateHasSubscribeFlags tests that docs create has --subscribe and --no-subscribe flags.
func TestDocsCreateHasSubscribeFlags(t *testing.T) {
cmd := NewFilesCmd()
Expand Down
16 changes: 7 additions & 9 deletions internal/commands/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strconv"
"unicode/utf8"

"github.com/basecamp/basecamp-sdk/go/pkg/basecamp"
"github.com/spf13/cobra"

"github.com/basecamp/basecamp-cli/internal/appctx"
Expand Down Expand Up @@ -174,17 +175,14 @@ For example, clone a Chat to create a second chat room in the same project.`,
return err
}

created, err := app.Account().Tools().Create(cmd.Context(), sourceToolID)
if err != nil {
return convertSDKError(err)
var cloneOpts *basecamp.CloneToolOptions
if title != "" {
cloneOpts = &basecamp.CloneToolOptions{Title: title}
}

// Rename the tool if a title was provided
if title != "" {
created, err = app.Account().Tools().Update(cmd.Context(), created.ID, title)
if err != nil {
return convertSDKError(err)
}
created, err := app.Account().Tools().Create(cmd.Context(), sourceToolID, cloneOpts)
if err != nil {
return convertSDKError(err)
}

return app.OK(created,
Expand Down
61 changes: 61 additions & 0 deletions internal/richtext/richtext.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,16 @@ var reMentionAnchor = regexp.MustCompile(`<a href="(mention|person):([^"]+)">([^
// Group 3: the SGID value (base64-safe characters).
var reSGIDMention = regexp.MustCompile(`(^|[\s>(\["'])(@sgid:([\w+=/-]+))`)

// Pre-compiled regexes for ExtractAttachments (inline file attachments in rich text)
var (
reFileAttachment = regexp.MustCompile(`(?i)<bc-attachment\b[^>]*>`)
reAttrHref = regexp.MustCompile(`(?i)\bhref="([^"]*)"`)
reAttrFilename = regexp.MustCompile(`(?i)\bfilename="([^"]*)"`)
reAttrFilesize = regexp.MustCompile(`(?i)\bfilesize="([^"]*)"`)
reAttrCT = regexp.MustCompile(`(?i)\bcontent-type="([^"]*)"`)
reAttrSGID = regexp.MustCompile(`(?i)\bsgid="([^"]*)"`)
)

// Pre-compiled regexes for IsHTML detection
var reSafeTag = regexp.MustCompile(`<(p|div|span|a|strong|b|em|i|code|pre|ul|ol|li|h[1-6]|blockquote|br|hr|img|bc-attachment)\b[^>]*>`)

Expand Down Expand Up @@ -920,6 +930,57 @@ func isInsideBcAttachment(s string, pos int) bool {
return true
}

// InlineAttachment holds metadata for a file attachment found in rich text content.
// These appear as <bc-attachment> elements with href pointing to storage URLs.
type InlineAttachment struct {
Href string
Filename string
Filesize string
ContentType string
SGID string
}

// ExtractAttachments parses <bc-attachment> elements from HTML and returns
// file attachments. Mentions (content-type="application/vnd.basecamp.mention")
// are excluded.
func ExtractAttachments(html string) []InlineAttachment {
if html == "" {
return nil
}

tags := reFileAttachment.FindAllString(html, -1)
var result []InlineAttachment
for _, tag := range tags {
// Skip mentions
ctMatch := reAttrCT.FindStringSubmatch(tag)
if len(ctMatch) >= 2 && strings.EqualFold(ctMatch[1], "application/vnd.basecamp.mention") {
continue
}

// Must have an href to be a downloadable attachment
hrefMatch := reAttrHref.FindStringSubmatch(tag)
if len(hrefMatch) < 2 || hrefMatch[1] == "" {
continue
}

a := InlineAttachment{Href: unescapeHTML(hrefMatch[1])}
if len(ctMatch) >= 2 {
a.ContentType = unescapeHTML(ctMatch[1])
}
if m := reAttrFilename.FindStringSubmatch(tag); len(m) >= 2 {
a.Filename = unescapeHTML(m[1])
}
if m := reAttrFilesize.FindStringSubmatch(tag); len(m) >= 2 {
a.Filesize = unescapeHTML(m[1])
}
if m := reAttrSGID.FindStringSubmatch(tag); len(m) >= 2 {
a.SGID = unescapeHTML(m[1])
}
result = append(result, a)
}
return result
}

// IsHTML attempts to detect if the input string contains HTML.
// Only returns true for well-formed HTML with common content tags.
// Does not detect arbitrary tags like <script> to prevent XSS passthrough.
Expand Down
Loading
Loading