mark/confluence/api.go
Manuel Rüger b7c9229da4 fix: RestrictPageUpdatesCloud now resolves allowedUser by name
The allowedUser parameter was completely ignored; the function always
restricted edits to the currently authenticated API user via
GetCurrentUser(). Resolve the specified user via GetUserByName first
and fall back to the current user only if that lookup fails, matching
the behaviour of RestrictPageUpdatesServer which uses the parameter
directly.

fix: paginate GetAttachments to handle pages with >100 attachments

The previous implementation fetched a single page of up to 1000
attachments. Pages with more than 1000 attachments would silently
miss some, causing attachment sync to skip or re-upload them.
Replace with a pagination loop (100 per page) that follows the
_links.next cursor until all attachments are retrieved.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 19:18:29 +01:00

866 lines
18 KiB
Go

package confluence
import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"unicode/utf8"
"github.com/kovetskiy/gopencils"
"github.com/kovetskiy/lorg"
"github.com/reconquest/karma-go"
"github.com/reconquest/pkg/log"
)
type User struct {
AccountID string `json:"accountId,omitempty"`
UserKey string `json:"userKey,omitempty"`
}
type API struct {
rest *gopencils.Resource
// it's deprecated accordingly to Atlassian documentation,
// but it's only way to set permissions
json *gopencils.Resource
BaseURL string
}
type SpaceInfo struct {
ID int `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Homepage PageInfo `json:"homepage"`
Links struct {
Full string `json:"webui"`
} `json:"_links"`
}
type PageInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Version struct {
Number int64 `json:"number"`
Message string `json:"message"`
} `json:"version"`
Ancestors []struct {
ID string `json:"id"`
Title string `json:"title"`
} `json:"ancestors"`
Links struct {
Full string `json:"webui"`
Base string `json:"-"` // Not from JSON; populated from response _links.base
} `json:"_links"`
}
type AttachmentInfo struct {
Filename string `json:"title"`
ID string `json:"id"`
Metadata struct {
Comment string `json:"comment"`
} `json:"metadata"`
Links struct {
Context string `json:"context"`
Download string `json:"download"`
} `json:"_links"`
}
type Label struct {
ID string `json:"id"`
Prefix string `json:"prefix"`
Name string `json:"name"`
}
type LabelInfo struct {
Labels []Label `json:"results"`
Size int `json:"number"`
}
type form struct {
buffer io.Reader
writer *multipart.Writer
}
type tracer struct {
prefix string
}
func (tracer *tracer) Printf(format string, args ...interface{}) {
log.Tracef(nil, tracer.prefix+" "+format, args...)
}
func NewAPI(baseURL string, username string, password string, insecureSkipVerify bool) *API {
var auth *gopencils.BasicAuth
if username != "" {
auth = &gopencils.BasicAuth{
Username: username,
Password: password,
}
}
var httpClient *http.Client
if insecureSkipVerify {
httpClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}
}
rest := gopencils.Api(baseURL+"/rest/api", auth, httpClient, 3) // set option for 3 retries on failure
if username == "" {
if rest.Headers == nil {
rest.Headers = http.Header{}
}
rest.SetHeader("Authorization", fmt.Sprintf("Bearer %s", password))
}
json := gopencils.Api(baseURL+"/rpc/json-rpc/confluenceservice-v2", auth, httpClient, 3)
if log.GetLevel() == lorg.LevelTrace {
rest.Logger = &tracer{"rest:"}
json.Logger = &tracer{"json-rpc:"}
}
return &API{
rest: rest,
json: json,
BaseURL: strings.TrimSuffix(baseURL, "/"),
}
}
func (api *API) FindRootPage(space string) (*PageInfo, error) {
page, err := api.FindPage(space, ``, "page")
if err != nil {
return nil, karma.Format(
err,
"can't obtain first page from space %q",
space,
)
}
if page == nil {
return nil, errors.New("no such space")
}
if len(page.Ancestors) == 0 {
return &PageInfo{
ID: page.ID,
Title: page.Title,
}, nil
}
return &PageInfo{
ID: page.Ancestors[0].ID,
Title: page.Ancestors[0].Title,
}, nil
}
func (api *API) FindHomePage(space string) (*PageInfo, error) {
payload := map[string]string{
"expand": "homepage",
}
request, err := api.rest.Res(
"space/"+space, &SpaceInfo{},
).Get(payload)
if err != nil {
return nil, err
}
if request.Raw.StatusCode == http.StatusNotFound || request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
return &request.Response.(*SpaceInfo).Homepage, nil
}
func (api *API) FindPage(
space string,
title string,
pageType string,
) (*PageInfo, error) {
result := struct {
Results []PageInfo `json:"results"`
Links struct {
Base string `json:"base"`
} `json:"_links"`
}{}
payload := map[string]string{
"spaceKey": space,
"expand": "ancestors,version",
"type": pageType,
}
if title != "" {
payload["title"] = title
}
request, err := api.rest.Res(
"content/", &result,
).Get(payload)
if err != nil {
return nil, err
}
// allow 404 because it's fine if page is not found,
// the function will return nil, nil
if request.Raw.StatusCode != http.StatusNotFound && request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
if len(result.Results) == 0 {
return nil, nil
}
page := &result.Results[0]
// Populate the base URL from the response _links.base
if result.Links.Base != "" {
page.Links.Base = result.Links.Base
}
return page, nil
}
func (api *API) CreateAttachment(
pageID string,
name string,
comment string,
reader io.Reader,
) (AttachmentInfo, error) {
var info AttachmentInfo
form, err := getAttachmentPayload(name, comment, reader)
if err != nil {
return AttachmentInfo{}, err
}
var result struct {
Links struct {
Context string `json:"context"`
} `json:"_links"`
Results []AttachmentInfo `json:"results"`
}
resource := api.rest.Res(
"content/"+pageID+"/child/attachment", &result,
)
resource.Payload = form.buffer
oldHeaders := resource.Headers.Clone()
resource.Headers = http.Header{}
if resource.Api.BasicAuth == nil {
resource.Headers.Set("Authorization", oldHeaders.Get("Authorization"))
}
resource.SetHeader("Content-Type", form.writer.FormDataContentType())
resource.SetHeader("X-Atlassian-Token", "no-check")
request, err := resource.Post()
if err != nil {
return info, err
}
if request.Raw.StatusCode != http.StatusOK {
return info, newErrorStatusNotOK(request)
}
if len(result.Results) == 0 {
return info, errors.New(
"the Confluence REST API for creating attachments returned " +
"0 json objects, expected at least 1",
)
}
for i, info := range result.Results {
if info.Links.Context == "" {
info.Links.Context = result.Links.Context
}
result.Results[i] = info
}
info = result.Results[0]
return info, nil
}
// UpdateAttachment uploads a new version of the same attachment if the
// checksums differs from the previous one.
// It also handles a case where Confluence returns sort of "short" variant of
// the response instead of an extended one.
func (api *API) UpdateAttachment(
pageID string,
attachID string,
name string,
comment string,
reader io.Reader,
) (AttachmentInfo, error) {
var info AttachmentInfo
form, err := getAttachmentPayload(name, comment, reader)
if err != nil {
return AttachmentInfo{}, err
}
var extendedResponse struct {
Links struct {
Context string `json:"context"`
} `json:"_links"`
Results []AttachmentInfo `json:"results"`
}
var result json.RawMessage
resource := api.rest.Res(
"content/"+pageID+"/child/attachment/"+attachID+"/data", &result,
)
resource.Payload = form.buffer
oldHeaders := resource.Headers.Clone()
resource.Headers = http.Header{}
if resource.Api.BasicAuth == nil {
resource.Headers.Set("Authorization", oldHeaders.Get("Authorization"))
}
resource.SetHeader("Content-Type", form.writer.FormDataContentType())
resource.SetHeader("X-Atlassian-Token", "no-check")
request, err := resource.Post()
if err != nil {
return info, err
}
if request.Raw.StatusCode != http.StatusOK {
return info, newErrorStatusNotOK(request)
}
err = json.Unmarshal(result, &extendedResponse)
if err != nil {
return info, karma.Format(
err,
"unable to unmarshal JSON response as full response format: %s",
string(result),
)
}
if len(extendedResponse.Results) > 0 {
for i, info := range extendedResponse.Results {
if info.Links.Context == "" {
info.Links.Context = extendedResponse.Links.Context
}
extendedResponse.Results[i] = info
}
info = extendedResponse.Results[0]
return info, nil
}
var shortResponse AttachmentInfo
err = json.Unmarshal(result, &shortResponse)
if err != nil {
return info, karma.Format(
err,
"unable to unmarshal JSON response as short response format: %s",
string(result),
)
}
return shortResponse, nil
}
func getAttachmentPayload(name, comment string, reader io.Reader) (*form, error) {
var (
payload = bytes.NewBuffer(nil)
writer = multipart.NewWriter(payload)
)
content, err := writer.CreateFormFile("file", name)
if err != nil {
return nil, karma.Format(
err,
"unable to create form file",
)
}
_, err = io.Copy(content, reader)
if err != nil {
return nil, karma.Format(
err,
"unable to copy i/o between form-file and file",
)
}
commentWriter, err := writer.CreateFormField("comment")
if err != nil {
return nil, karma.Format(
err,
"unable to create form field for comment",
)
}
_, err = commentWriter.Write([]byte(comment))
if err != nil {
return nil, karma.Format(
err,
"unable to write comment in form-field",
)
}
err = writer.Close()
if err != nil {
return nil, karma.Format(
err,
"unable to close form-writer",
)
}
return &form{
buffer: payload,
writer: writer,
}, nil
}
func (api *API) GetAttachments(pageID string) ([]AttachmentInfo, error) {
type page struct {
Links struct {
Context string `json:"context"`
Next string `json:"next"`
} `json:"_links"`
Results []AttachmentInfo `json:"results"`
}
const pageSize = 100
var all []AttachmentInfo
start := 0
for {
var result page
payload := map[string]string{
"expand": "version,container",
"limit": fmt.Sprintf("%d", pageSize),
"start": fmt.Sprintf("%d", start),
}
request, err := api.rest.Res(
"content/"+pageID+"/child/attachment", &result,
).Get(payload)
if err != nil {
return nil, err
}
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
for i, info := range result.Results {
if info.Links.Context == "" {
info.Links.Context = result.Links.Context
}
result.Results[i] = info
}
all = append(all, result.Results...)
if len(result.Results) < pageSize || result.Links.Next == "" {
break
}
start += len(result.Results)
}
return all, nil
}
func (api *API) GetPageByID(pageID string) (*PageInfo, error) {
request, err := api.rest.Res(
"content/"+pageID, &PageInfo{},
).Get(map[string]string{"expand": "ancestors,version"})
if err != nil {
return nil, err
}
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
return request.Response.(*PageInfo), nil
}
func (api *API) CreatePage(
space string,
pageType string,
parent *PageInfo,
title string,
body string,
) (*PageInfo, error) {
payload := map[string]interface{}{
"type": pageType,
"title": title,
"space": map[string]interface{}{
"key": space,
},
"body": map[string]interface{}{
"storage": map[string]interface{}{
"representation": "storage",
"value": body,
},
},
"metadata": map[string]interface{}{
"properties": map[string]interface{}{
"editor": map[string]interface{}{
"value": "v2",
},
},
},
}
if parent != nil {
payload["ancestors"] = []map[string]interface{}{
{"id": parent.ID},
}
}
request, err := api.rest.Res(
"content/", &PageInfo{},
).Post(payload)
if err != nil {
return nil, err
}
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
return request.Response.(*PageInfo), nil
}
func (api *API) UpdatePage(page *PageInfo, newContent string, minorEdit bool, versionMessage string, appearance string, emojiString string) error {
nextPageVersion := page.Version.Number + 1
oldAncestors := []map[string]interface{}{}
if page.Type != "blogpost" && len(page.Ancestors) > 0 {
// picking only the last one, which is required by confluence
oldAncestors = []map[string]interface{}{
{"id": page.Ancestors[len(page.Ancestors)-1].ID},
}
}
properties := map[string]interface{}{
// Fix to set full-width as has changed on Confluence APIs again.
// https://jira.atlassian.com/browse/CONFCLOUD-65447
//
"content-appearance-published": map[string]interface{}{
"value": appearance,
},
// content-appearance-draft should not be set as this is impacted by
// the user editor default configurations - which caused the sporadic published widths.
}
if emojiString != "" {
r, size := utf8.DecodeRuneInString(emojiString)
if r == utf8.RuneError && size <= 1 {
return fmt.Errorf("invalid UTF-8 in emoji: %q", emojiString)
}
unicodeHex := fmt.Sprintf("%x", r)
properties["emoji-title-draft"] = map[string]interface{}{
"value": unicodeHex,
}
properties["emoji-title-published"] = map[string]interface{}{
"value": unicodeHex,
}
}
payload := map[string]interface{}{
"id": page.ID,
"type": page.Type,
"title": page.Title,
"version": map[string]interface{}{
"number": nextPageVersion,
"minorEdit": minorEdit,
"message": versionMessage,
},
"ancestors": oldAncestors,
"body": map[string]interface{}{
"storage": map[string]interface{}{
"value": newContent,
"representation": "storage",
},
},
"metadata": map[string]interface{}{
"properties": properties,
},
}
request, err := api.rest.Res(
"content/"+page.ID, &map[string]interface{}{},
).Put(payload)
if err != nil {
return err
}
if request.Raw.StatusCode != http.StatusOK {
return newErrorStatusNotOK(request)
}
return nil
}
func (api *API) AddPageLabels(page *PageInfo, newLabels []string) (*LabelInfo, error) {
labels := []map[string]interface{}{}
for _, label := range newLabels {
if label != "" {
item := map[string]interface{}{
"prefix": "global",
"name": label,
}
labels = append(labels, item)
}
}
payload := labels
request, err := api.rest.Res(
"content/"+page.ID+"/label", &LabelInfo{},
).Post(payload)
if err != nil {
return nil, err
}
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
return request.Response.(*LabelInfo), nil
}
func (api *API) DeletePageLabel(page *PageInfo, label string) (*LabelInfo, error) {
request, err := api.rest.Res(
"content/"+page.ID+"/label", &LabelInfo{},
).SetQuery(map[string]string{"name": label}).Delete()
if err != nil {
return nil, err
}
if request.Raw.StatusCode != http.StatusOK && request.Raw.StatusCode != http.StatusNoContent {
return nil, newErrorStatusNotOK(request)
}
return request.Response.(*LabelInfo), nil
}
func (api *API) GetPageLabels(page *PageInfo, prefix string) (*LabelInfo, error) {
request, err := api.rest.Res(
"content/"+page.ID+"/label", &LabelInfo{},
).Get(map[string]string{"prefix": prefix})
if err != nil {
return nil, err
}
if request.Raw.StatusCode != http.StatusOK {
return nil, newErrorStatusNotOK(request)
}
return request.Response.(*LabelInfo), nil
}
func (api *API) GetUserByName(name string) (*User, error) {
var response struct {
Results []struct {
User User
}
}
// Try the new path first
_, err := api.rest.
Res("search").
Res("user", &response).
Get(map[string]string{
"cql": fmt.Sprintf("user.fullname~%q", name),
})
if err != nil {
return nil, err
}
// Try old path
if len(response.Results) == 0 {
_, err := api.rest.
Res("search", &response).
Get(map[string]string{
"cql": fmt.Sprintf("user.fullname~%q", name),
})
if err != nil {
return nil, err
}
}
if len(response.Results) == 0 {
return nil, karma.
Describe("name", name).
Reason(
"user with given name is not found",
)
}
return &response.Results[0].User, nil
}
func (api *API) GetCurrentUser() (*User, error) {
var user User
_, err := api.rest.
Res("user").
Res("current", &user).
Get()
if err != nil {
return nil, err
}
return &user, nil
}
func (api *API) RestrictPageUpdatesCloud(
page *PageInfo,
allowedUser string,
) error {
user, err := api.GetUserByName(allowedUser)
if err != nil {
// Fall back to the currently authenticated user if the specified
// user cannot be resolved by name (e.g. on Confluence Cloud where
// only accountId is accepted and name lookup may fail).
currentUser, currentErr := api.GetCurrentUser()
if currentErr != nil {
return fmt.Errorf("unable to resolve user %q: %w", allowedUser, err)
}
user = currentUser
}
var result interface{}
request, err := api.rest.
Res("content").
Id(page.ID).
Res("restriction", &result).
Post([]map[string]interface{}{
{
"operation": "update",
"restrictions": map[string]interface{}{
"user": []map[string]interface{}{
{
"type": "known",
"accountId": user.AccountID,
},
},
},
},
})
if err != nil {
return err
}
if request.Raw.StatusCode != http.StatusOK {
return newErrorStatusNotOK(request)
}
return nil
}
func (api *API) RestrictPageUpdatesServer(
page *PageInfo,
allowedUser string,
) error {
var (
err error
result interface{}
)
request, err := api.json.Res(
"setContentPermissions", &result,
).Post([]interface{}{
page.ID,
"Edit",
[]map[string]interface{}{
{
"userName": allowedUser,
},
},
})
if err != nil {
return err
}
if request.Raw.StatusCode != http.StatusOK {
return newErrorStatusNotOK(request)
}
if success, ok := result.(bool); !ok || !success {
return fmt.Errorf(
"'true' response expected, but '%v' encountered",
result,
)
}
return nil
}
func (api *API) RestrictPageUpdates(
page *PageInfo,
allowedUser string,
) error {
var err error
if strings.HasSuffix(api.rest.Api.BaseUrl.Host, "jira.com") || strings.HasSuffix(api.rest.Api.BaseUrl.Host, "atlassian.net") {
err = api.RestrictPageUpdatesCloud(page, allowedUser)
} else {
err = api.RestrictPageUpdatesServer(page, allowedUser)
}
return err
}
func newErrorStatusNotOK(request *gopencils.Resource) error {
if request.Raw.StatusCode == http.StatusUnauthorized {
return errors.New(
"the Confluence API returned unexpected status: 401 (Unauthorized)",
)
}
if request.Raw.StatusCode == http.StatusNotFound {
return errors.New(
"the Confluence API returned unexpected status: 404 (Not Found)",
)
}
defer func() {
_ = request.Raw.Body.Close()
}()
output, _ := io.ReadAll(request.Raw.Body)
return fmt.Errorf(
"the Confluence API returned unexpected status: %v, "+
"output: %q",
request.Raw.Status, output,
)
}