Skip to content

Commit 5903ac8

Browse files
authored
feat: add Confluence page attachment commands (list, download, upload) (#35)
* feat: add Confluence attachment structs and list API methods Add ConfluenceAttachment, AttachmentVersion, and ConfluenceAttachmentsResponse structs plus GetPageAttachments, GetPageAttachmentsAll, and GetConfluenceAttachment methods to ConfluenceService, following existing pagination patterns. * feat: add Confluence attachment download and upload API methods Add DownloadConfluenceAttachment (via GetRaw) and UploadConfluenceAttachment (via v1 POST multipart) with supporting response structs. * feat: add Confluence page attachment command Add the `atl confluence page attachment` command supporting list, download, download-all, and upload operations for Confluence page attachments. Register the new subcommand in the page command group. * docs: add Confluence page attachment examples to AGENTS.md and README.md * fix: address Gemini review findings - Sanitize attachment filenames with filepath.Base() to prevent path traversal - Use API-reported file size instead of len(content) in download outputs - Show upload summary on single-file upload when there are errors
1 parent cd5b5cd commit 5903ac8

File tree

6 files changed

+720
-0
lines changed

6 files changed

+720
-0
lines changed

AGENTS.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,18 @@ atl confluence template create --space DOCS --name "Name" --body "<html>"
186186
atl confluence template update <id> --body "<html>"
187187
```
188188

189+
### Attachments
190+
191+
```bash
192+
atl confluence page attachment <id> --list # List attachments
193+
atl confluence page attachment <id> --list --json # List as JSON
194+
atl confluence page attachment <id> --download --id <attID> # Download specific
195+
atl confluence page attachment <id> --download-all # Download all
196+
atl confluence page attachment <id> --download-all -o ./dir # Download to directory
197+
atl confluence page attachment <id> --upload ./file.pdf # Upload file
198+
atl confluence page attachment <id> --upload a.pdf --upload b.png # Upload multiple
199+
```
200+
189201
## Formatting Guidelines
190202

191203
### Jira Formatting (Markdown to ADF)

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,14 @@ atl confluence page archive <id> --unarchive # Restore archived page
242242
atl confluence page move <id> --target <parent-id> # Move as child of target
243243
atl confluence page move <id> --target <sibling-id> --position before # Move before sibling
244244
atl confluence page move <id> --space NEWSPACE # Move to different space
245+
246+
atl confluence page attachment <id> --list # List attachments
247+
atl confluence page attachment <id> --list --json # List as JSON
248+
atl confluence page attachment <id> --download --id <attID> # Download specific
249+
atl confluence page attachment <id> --download-all # Download all
250+
atl confluence page attachment <id> --download-all -o ./dir # Download to directory
251+
atl confluence page attachment <id> --upload ./file.pdf # Upload file
252+
atl confluence page attachment <id> --upload a.pdf --upload b.png # Upload multiple
245253
```
246254

247255
### Configuration

internal/api/confluence.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,3 +885,128 @@ func (s *ConfluenceService) UpdateTemplate(ctx context.Context, templateID, name
885885

886886
return &template, nil
887887
}
888+
889+
// ConfluenceAttachment represents a file attachment on a Confluence page.
890+
type ConfluenceAttachment struct {
891+
ID string `json:"id"`
892+
Title string `json:"title"`
893+
MediaType string `json:"mediaType"`
894+
FileSize int64 `json:"fileSize"`
895+
Status string `json:"status"`
896+
Version *AttachmentVersion `json:"version,omitempty"`
897+
DownloadLink string `json:"downloadLink,omitempty"`
898+
}
899+
900+
// AttachmentVersion represents version info for an attachment.
901+
type AttachmentVersion struct {
902+
Number int `json:"number"`
903+
CreatedAt string `json:"createdAt,omitempty"`
904+
AuthorID string `json:"authorId,omitempty"`
905+
}
906+
907+
// ConfluenceAttachmentsResponse represents a paginated list of attachments.
908+
type ConfluenceAttachmentsResponse struct {
909+
Results []*ConfluenceAttachment `json:"results"`
910+
Links *PaginationLinks `json:"_links,omitempty"`
911+
}
912+
913+
// GetPageAttachments lists attachments on a Confluence page.
914+
// Uses v2 API: GET /pages/{pageID}/attachments
915+
func (s *ConfluenceService) GetPageAttachments(ctx context.Context, pageID string, limit int, cursor string) (*ConfluenceAttachmentsResponse, error) {
916+
path := fmt.Sprintf("%s/pages/%s/attachments", s.baseURL(), pageID)
917+
918+
params := url.Values{}
919+
if limit > 0 {
920+
params.Set("limit", strconv.Itoa(capLimit(limit, ConfluenceMaxLimit)))
921+
}
922+
if cursor != "" {
923+
params.Set("cursor", cursor)
924+
}
925+
926+
var result ConfluenceAttachmentsResponse
927+
if err := s.client.Get(ctx, path+"?"+params.Encode(), &result); err != nil {
928+
return nil, err
929+
}
930+
931+
return &result, nil
932+
}
933+
934+
// GetPageAttachmentsAll lists all attachments on a page by following pagination.
935+
func (s *ConfluenceService) GetPageAttachmentsAll(ctx context.Context, pageID string) ([]*ConfluenceAttachment, error) {
936+
var all []*ConfluenceAttachment
937+
cursor := ""
938+
939+
for {
940+
result, err := s.GetPageAttachments(ctx, pageID, 100, cursor)
941+
if err != nil {
942+
return nil, err
943+
}
944+
all = append(all, result.Results...)
945+
946+
if result.Links == nil || result.Links.Next == "" {
947+
break
948+
}
949+
cursor = extractCursor(result.Links.Next)
950+
if cursor == "" {
951+
break
952+
}
953+
}
954+
955+
return all, nil
956+
}
957+
958+
// GetConfluenceAttachment gets a single attachment by ID.
959+
// Uses v2 API: GET /attachments/{id}
960+
func (s *ConfluenceService) GetConfluenceAttachment(ctx context.Context, attachmentID string) (*ConfluenceAttachment, error) {
961+
path := fmt.Sprintf("%s/attachments/%s", s.baseURL(), attachmentID)
962+
963+
var attachment ConfluenceAttachment
964+
if err := s.client.Get(ctx, path, &attachment); err != nil {
965+
return nil, err
966+
}
967+
968+
return &attachment, nil
969+
}
970+
971+
// DownloadConfluenceAttachment downloads an attachment's content.
972+
// downloadLink is the relative link from the attachment's downloadLink field.
973+
// The full URL is constructed by prepending the Confluence site base.
974+
func (s *ConfluenceService) DownloadConfluenceAttachment(ctx context.Context, downloadLink string) ([]byte, string, error) {
975+
// downloadLink is relative (e.g. "/download/attachments/123/file.pdf")
976+
// Construct full URL via the Confluence site
977+
fullURL := fmt.Sprintf("%s/ex/confluence/%s/wiki%s", AtlassianAPIURL, s.client.CloudID(), downloadLink)
978+
return s.client.GetRaw(ctx, fullURL)
979+
}
980+
981+
// UploadConfluenceAttachment uploads a file as an attachment to a Confluence page.
982+
// Uses v1 API: POST /content/{pageID}/child/attachment
983+
func (s *ConfluenceService) UploadConfluenceAttachment(ctx context.Context, pageID, filePath string) (*ConfluenceUploadResponse, error) {
984+
path := fmt.Sprintf("%s/content/%s/child/attachment", s.baseURLV1(), pageID)
985+
986+
var result ConfluenceUploadResponse
987+
if err := s.client.PostMultipart(ctx, path, "file", filePath, &result); err != nil {
988+
return nil, err
989+
}
990+
991+
return &result, nil
992+
}
993+
994+
// ConfluenceUploadResponse represents the v1 response when uploading attachments.
995+
type ConfluenceUploadResponse struct {
996+
Results []*ConfluenceUploadResult `json:"results"`
997+
}
998+
999+
// ConfluenceUploadResult represents a single uploaded attachment in the v1 response.
1000+
type ConfluenceUploadResult struct {
1001+
ID string `json:"id"`
1002+
Title string `json:"title"`
1003+
MediaType string `json:"mediaType,omitempty"`
1004+
FileSize int64 `json:"fileSize,omitempty"`
1005+
Extensions *ConfluenceUploadMetadata `json:"extensions,omitempty"`
1006+
}
1007+
1008+
// ConfluenceUploadMetadata holds metadata from the v1 upload response.
1009+
type ConfluenceUploadMetadata struct {
1010+
MediaType string `json:"mediaType,omitempty"`
1011+
FileSize int64 `json:"fileSize,omitempty"`
1012+
}

internal/api/confluence_test.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,120 @@ func TestSpaceDescription(t *testing.T) {
349349
}
350350
}
351351

352+
// TestConfluenceAttachmentStructure tests the ConfluenceAttachment JSON serialization.
353+
func TestConfluenceAttachmentStructure(t *testing.T) {
354+
att := &ConfluenceAttachment{
355+
ID: "att123",
356+
Title: "screenshot.png",
357+
MediaType: "image/png",
358+
FileSize: 12345,
359+
Status: "current",
360+
Version: &AttachmentVersion{
361+
Number: 1,
362+
CreatedAt: "2026-01-15T10:00:00.000Z",
363+
},
364+
DownloadLink: "/download/attachments/456/screenshot.png",
365+
}
366+
367+
data, err := json.Marshal(att)
368+
if err != nil {
369+
t.Fatalf("json.Marshal() error = %v", err)
370+
}
371+
372+
var decoded ConfluenceAttachment
373+
if err := json.Unmarshal(data, &decoded); err != nil {
374+
t.Fatalf("json.Unmarshal() error = %v", err)
375+
}
376+
377+
if decoded.ID != "att123" {
378+
t.Errorf("ID = %q, want %q", decoded.ID, "att123")
379+
}
380+
if decoded.Title != "screenshot.png" {
381+
t.Errorf("Title = %q, want %q", decoded.Title, "screenshot.png")
382+
}
383+
if decoded.FileSize != 12345 {
384+
t.Errorf("FileSize = %d, want 12345", decoded.FileSize)
385+
}
386+
if decoded.DownloadLink != "/download/attachments/456/screenshot.png" {
387+
t.Errorf("DownloadLink = %q, want %q", decoded.DownloadLink, "/download/attachments/456/screenshot.png")
388+
}
389+
}
390+
391+
// TestGetPageAttachments tests listing attachments on a page.
392+
func TestGetPageAttachments(t *testing.T) {
393+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
394+
if r.Method != http.MethodGet {
395+
t.Errorf("Method = %s, want GET", r.Method)
396+
}
397+
398+
response := ConfluenceAttachmentsResponse{
399+
Results: []*ConfluenceAttachment{
400+
{ID: "att1", Title: "file1.pdf", MediaType: "application/pdf", FileSize: 1000},
401+
{ID: "att2", Title: "file2.png", MediaType: "image/png", FileSize: 2000},
402+
},
403+
Links: &PaginationLinks{},
404+
}
405+
406+
w.Header().Set("Content-Type", "application/json")
407+
json.NewEncoder(w).Encode(response)
408+
}))
409+
defer server.Close()
410+
411+
client := &Client{
412+
httpClient: server.Client(),
413+
cloudID: "test-cloud",
414+
tokens: &auth.TokenSet{
415+
AccessToken: "test-token",
416+
ExpiresAt: time.Now().Add(time.Hour),
417+
},
418+
}
419+
420+
ctx := context.Background()
421+
var result ConfluenceAttachmentsResponse
422+
err := client.Get(ctx, server.URL, &result)
423+
424+
if err != nil {
425+
t.Fatalf("error = %v", err)
426+
}
427+
if len(result.Results) != 2 {
428+
t.Errorf("got %d attachments, want 2", len(result.Results))
429+
}
430+
if result.Results[0].Title != "file1.pdf" {
431+
t.Errorf("first attachment title = %q, want %q", result.Results[0].Title, "file1.pdf")
432+
}
433+
}
434+
435+
// TestConfluenceUploadResponseStructure tests the upload response JSON serialization.
436+
func TestConfluenceUploadResponseStructure(t *testing.T) {
437+
jsonData := `{"results": [{"id": "att100", "title": "report.pdf", "extensions": {"mediaType": "application/pdf", "fileSize": 54321}}]}`
438+
439+
var response ConfluenceUploadResponse
440+
if err := json.Unmarshal([]byte(jsonData), &response); err != nil {
441+
t.Fatalf("json.Unmarshal() error = %v", err)
442+
}
443+
444+
if len(response.Results) != 1 {
445+
t.Fatalf("Results count = %d, want 1", len(response.Results))
446+
}
447+
448+
r := response.Results[0]
449+
if r.ID != "att100" {
450+
t.Errorf("ID = %q, want %q", r.ID, "att100")
451+
}
452+
if r.Title != "report.pdf" {
453+
t.Errorf("Title = %q, want %q", r.Title, "report.pdf")
454+
}
455+
if r.Extensions == nil {
456+
t.Fatal("Extensions is nil")
457+
}
458+
if r.Extensions.MediaType != "application/pdf" {
459+
t.Errorf("Extensions.MediaType = %q, want %q", r.Extensions.MediaType, "application/pdf")
460+
}
461+
if r.Extensions.FileSize != 54321 {
462+
t.Errorf("Extensions.FileSize = %d, want 54321", r.Extensions.FileSize)
463+
}
464+
}
465+
352466
// TestPageBodyFormats tests the PageBody structure with different formats.
353467
func TestPageBodyFormats(t *testing.T) {
354468
body := &PageBody{
@@ -369,3 +483,33 @@ func TestPageBodyFormats(t *testing.T) {
369483
t.Errorf("PageBody.View.Representation = %q, want %q", body.View.Representation, "view")
370484
}
371485
}
486+
487+
// TestDownloadConfluenceAttachment tests downloading attachment content.
488+
func TestDownloadConfluenceAttachment(t *testing.T) {
489+
fileContent := []byte("file content here")
490+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
491+
w.Header().Set("Content-Type", "application/pdf")
492+
w.Write(fileContent)
493+
}))
494+
defer server.Close()
495+
496+
client := &Client{
497+
httpClient: server.Client(),
498+
cloudID: "test-cloud",
499+
tokens: &auth.TokenSet{
500+
AccessToken: "test-token",
501+
ExpiresAt: time.Now().Add(time.Hour),
502+
},
503+
}
504+
505+
content, contentType, err := client.GetRaw(context.Background(), server.URL)
506+
if err != nil {
507+
t.Fatalf("error = %v", err)
508+
}
509+
if string(content) != "file content here" {
510+
t.Errorf("content = %q, want %q", string(content), "file content here")
511+
}
512+
if contentType != "application/pdf" {
513+
t.Errorf("contentType = %q, want %q", contentType, "application/pdf")
514+
}
515+
}

0 commit comments

Comments
 (0)