package confluence import ( "bytes" "errors" "fmt" "io" "io/ioutil" "mime/multipart" "net/http" "os" "github.com/bndr/gopencils" "github.com/kovetskiy/lorg" "github.com/reconquest/cog" "github.com/reconquest/karma-go" ) type RestrictionOperation string const ( RestrictionEdit RestrictionOperation = `Edit` RestrictionView = `View` ) type Restriction struct { User string `json:"userName"` Group string `json:"groupName",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 } type PageInfo struct { ID string `json:"id"` Title string `json:"title"` Version struct { Number int64 `json:"number"` } `json:"version"` Ancestors []struct { Id string `json:"id"` Title string `json:"title"` } `json:"ancestors"` Links struct { Full string `json:"webui"` } `json:"_links"` } type AttachmentInfo struct { Filename string `json:"title"` ID string `json:"id"` Metadata struct { Comment string `json:"comment"` } `json:"metadata"` Links struct { Download string `json:"download"` } `json:"_links"` } func discarder() *lorg.Log { stderr := lorg.NewLog() stderr.SetOutput(ioutil.Discard) return stderr } var ( log = cog.NewLogger(discarder()) ) func SetLogger(logger *cog.Logger) { log = logger } type form struct { buffer io.Reader writer *multipart.Writer } func NewAPI(baseURL string, username string, password string) *API { auth := &gopencils.BasicAuth{username, password} return &API{ rest: gopencils.Api(baseURL+"/rest/api", auth), json: gopencils.Api( baseURL+"/rpc/json-rpc/confluenceservice-v2", auth, ), } } func (api *API) FindRootPage(space string) (*PageInfo, error) { page, err := api.FindPage(space, ``) 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) FindPage(space string, title string) (*PageInfo, error) { result := struct { Results []PageInfo `json:"results"` }{} payload := map[string]string{ "spaceKey": space, "expand": "ancestors,version", } 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 != 404 && request.Raw.StatusCode != 200 { return nil, newErrorStatusNotOK(request) } if len(result.Results) == 0 { return nil, nil } return &result.Results[0], nil } func (api *API) CreateAttachment( pageID string, name string, comment string, path string, ) (AttachmentInfo, error) { var info AttachmentInfo form, err := getAttachmentPayload(name, comment, path) if err != nil { return AttachmentInfo{}, err } var result struct { Results []AttachmentInfo `json:"results"` } resource := api.rest.Res( "content/"+pageID+"/child/attachment", &result, ) resource.Payload = form.buffer resource.Headers = http.Header{} 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 != 200 { return info, newErrorStatusNotOK(request) } if len(result.Results) == 0 { return info, errors.New( "Confluence REST API for creating attachments returned " + "0 json objects, expected at least 1", ) } info = result.Results[0] return info, nil } func (api *API) UpdateAttachment( pageID string, attachID string, name string, comment string, path string, ) (AttachmentInfo, error) { var info AttachmentInfo form, err := getAttachmentPayload(name, comment, path) if err != nil { return AttachmentInfo{}, err } var result struct { Results []AttachmentInfo `json:"results"` } resource := api.rest.Res( "content/"+pageID+"/child/attachment/"+attachID+"/data", &result, ) resource.Payload = form.buffer resource.Headers = http.Header{} 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 != 200 { return info, newErrorStatusNotOK(request) } if len(result.Results) == 0 { return info, errors.New( "Confluence REST API for creating attachments returned " + "0 json objects, expected at least 1", ) } info = result.Results[0] return info, nil } func getAttachmentPayload(name, comment, path string) (*form, error) { var ( payload = bytes.NewBuffer(nil) writer = multipart.NewWriter(payload) ) file, err := os.Open(path) if err != nil { return nil, karma.Format( err, "unable to open file: %q", path, ) } defer file.Close() content, err := writer.CreateFormFile("file", name) if err != nil { return nil, karma.Format( err, "unable to create form file", ) } _, err = io.Copy(content, file) 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) { result := struct { Results []AttachmentInfo `json:"results"` }{} payload := map[string]string{ "expand": "version,container", } request, err := api.rest.Res( "content/"+pageID+"/child/attachment", &result, ).Get(payload) if err != nil { return nil, err } if request.Raw.StatusCode != 200 { return nil, newErrorStatusNotOK(request) } return result.Results, 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 != 200 { return nil, newErrorStatusNotOK(request) } return request.Response.(*PageInfo), nil } func (api *API) CreatePage( space string, parent *PageInfo, title string, body string, ) (*PageInfo, error) { payload := map[string]interface{}{ "type": "page", "title": title, "space": map[string]interface{}{ "key": space, }, "body": map[string]interface{}{ "storage": map[string]interface{}{ "representation": "storage", "value": body, }, }, } 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 != 200 { return nil, newErrorStatusNotOK(request) } return request.Response.(*PageInfo), nil } func (api *API) UpdatePage( page *PageInfo, newContent string, ) error { nextPageVersion := page.Version.Number + 1 if len(page.Ancestors) == 0 { return fmt.Errorf( "page %q info does not contain any information about parents", page.ID, ) } // picking only the last one, which is required by confluence oldAncestors := []map[string]interface{}{ {"id": page.Ancestors[len(page.Ancestors)-1].Id}, } payload := map[string]interface{}{ "id": page.ID, "type": "page", "title": page.Title, "version": map[string]interface{}{ "number": nextPageVersion, "minorEdit": false, }, "ancestors": oldAncestors, "body": map[string]interface{}{ "storage": map[string]interface{}{ "value": string(newContent), "representation": "storage", }, }, } request, err := api.rest.Res( "content/"+page.ID, &map[string]interface{}{}, ).Put(payload) if err != nil { return err } if request.Raw.StatusCode != 200 { return newErrorStatusNotOK(request) } return nil } func (api *API) SetPagePermissions( page *PageInfo, operation RestrictionOperation, restrictions []Restriction, ) error { var result interface{} request, err := api.json.Res( "setContentPermissions", &result, ).Post([]interface{}{ page.ID, operation, restrictions, }) if err != nil { return err } if request.Raw.StatusCode != 200 { return newErrorStatusNotOK(request) } if success, ok := result.(bool); !ok || !success { return fmt.Errorf( "'true' response expected, but '%v' encountered", result, ) } return nil } func newErrorStatusNotOK(request *gopencils.Resource) error { if request.Raw.StatusCode == 401 { return errors.New( "Confluence API returned unexpected status: 401 (Unauthorized)", ) } if request.Raw.StatusCode == 404 { return errors.New( "Confluence API returned unexpected status: 404 (Not Found)", ) } output, _ := ioutil.ReadAll(request.Raw.Body) defer request.Raw.Body.Close() return fmt.Errorf( "Confluence API returned unexpected status: %v, "+ "output: %s", request.Raw.Status, output, ) }