diff --git a/page/link.go b/page/link.go index e33510c..a1b270f 100644 --- a/page/link.go +++ b/page/link.go @@ -2,12 +2,14 @@ package page import ( "bytes" + "encoding/base64" + "encoding/binary" "fmt" "net/http" - "net/url" "os" "path/filepath" "regexp" + "strconv" "strings" "github.com/kovetskiy/mark/confluence" @@ -200,8 +202,9 @@ func parseLinks(markdown string) []markdownLink { return links } -// getConfluenceLink build (to be) link for Confluence, and tries to verify from -// API if there's real link available +// getConfluenceLink builds a stable Confluence tiny link for the given page. +// Tiny links use the format {baseURL}/x/{encodedPageID} and are immune to +// Cloud-specific URL variations like /ex/confluence//wiki/... func getConfluenceLink( api *confluence.API, space, title string, @@ -211,69 +214,68 @@ func getConfluenceLink( return "", karma.Format(err, "api: find page") } if page == nil { - // Without a page ID there is no stable way to produce - // /wiki/spaces//pages//. return "", nil } - // Confluence Cloud web UI URLs can be returned either as a path ("/wiki/..." or - // "/ex/confluence//wiki/...") or as a full absolute URL. - absolute, err := makeAbsoluteConfluenceWebUIURL(api.BaseURL, page.Links.Full) + tiny, err := GenerateTinyLink(api.BaseURL, page.ID) if err != nil { - return "", karma.Format(err, "build confluence webui URL") + return "", karma.Format(err, "generate tiny link for page %s", page.ID) } - return absolute, nil + return tiny, nil } -func makeAbsoluteConfluenceWebUIURL(baseURL string, webui string) (string, error) { - if webui == "" { - return "", nil - } - - u, err := url.Parse(webui) +// GenerateTinyLink generates a Confluence tiny link from a page ID. +// The algorithm converts the page ID to a little-endian 32-bit byte array, +// base64-encodes it, and applies URL-safe transformations. +// Format: {baseURL}/x/{encodedID} +// +// Reference: https://support.atlassian.com/confluence/kb/how-to-programmatically-generate-the-tiny-link-of-a-confluence-page +func GenerateTinyLink(baseURL string, pageID string) (string, error) { + id, err := strconv.ParseUint(pageID, 10, 64) if err != nil { - return "", err - } - - path := normalizeConfluenceWebUIPath(u.Path) - if path == "" { - return "", nil - } - - // If Confluence returns an absolute URL, trust its host/scheme. - if u.Scheme != "" && u.Host != "" { - baseURL = u.Scheme + "://" + u.Host + return "", fmt.Errorf("invalid page ID %q: %w", pageID, err) } + encoded := encodeTinyLinkID(id) baseURL = strings.TrimSuffix(baseURL, "/") - if !strings.HasPrefix(path, "/") { - path = "/" + path - } - result := baseURL + path - if u.RawQuery != "" { - result += "?" + u.RawQuery - } - if u.Fragment != "" { - result += "#" + u.Fragment - } - - return result, nil + return baseURL + "/x/" + encoded, nil } -// normalizeConfluenceWebUIPath rewrites Confluence Cloud "experience" URLs -// ("/ex/confluence//wiki/..."), to canonical wiki paths ("/wiki/..."). -func normalizeConfluenceWebUIPath(path string) string { - if path == "" { - return path +// encodeTinyLinkID encodes a page ID into the Confluence tiny link format. +// This is the core algorithm extracted for testability. +func encodeTinyLinkID(id uint64) string { + // Pack as little-endian. Use 8 bytes to support large page IDs, + // but the base64 trimming will remove unnecessary trailing zeros. + buf := make([]byte, 8) + binary.LittleEndian.PutUint64(buf, id) + + // Trim trailing zero bytes (they become 'A' padding in base64) + for len(buf) > 1 && buf[len(buf)-1] == 0 { + buf = buf[:len(buf)-1] } - re := regexp.MustCompile(`^/ex/confluence/[^/]+(/wiki/.*)$`) - match := re.FindStringSubmatch(path) - if len(match) == 2 { - return match[1] + // Base64 encode + encoded := base64.StdEncoding.EncodeToString(buf) + + // Transform to URL-safe format: + // - Strip '=' padding + // - Replace '/' with '-' + // - Replace '+' with '_' + var result strings.Builder + for _, c := range encoded { + switch c { + case '=': + continue + case '/': + result.WriteByte('-') + case '+': + result.WriteByte('_') + default: + result.WriteRune(c) + } } - return path + return result.String() } diff --git a/page/link_test.go b/page/link_test.go index 3acf808..7a9f63d 100644 --- a/page/link_test.go +++ b/page/link_test.go @@ -1,6 +1,9 @@ package page import ( + "encoding/base64" + "encoding/binary" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -52,15 +55,152 @@ func TestParseLinks(t *testing.T) { assert.Equal(t, len(links), 8) } -func TestNormalizeConfluenceWebUIPath(t *testing.T) { - t.Run("confluence-cloud-experience-prefix", func(t *testing.T) { - input := "/ex/confluence/cloud-id/wiki/spaces/SPACE/pages/12345/PageName" - expected := "/wiki/spaces/SPACE/pages/12345/PageName" - assert.Equal(t, expected, normalizeConfluenceWebUIPath(input)) - }) +func TestEncodeTinyLinkID(t *testing.T) { + // Test cases for the tiny link encoding algorithm. + // The algorithm: little-endian bytes -> base64 -> URL-safe transform + tests := []struct { + name string + pageID uint64 + expected string + }{ + { + name: "small page ID", + pageID: 98319, + expected: "D4AB", + }, + { + name: "another small page ID", + pageID: 98320, + expected: "EIAB", + }, + { + name: "large page ID from user example", + pageID: 5645697027, + expected: "A4CCUAE", + }, + { + name: "page ID 1", + pageID: 1, + expected: "AQ", + }, + { + name: "page ID 255", + pageID: 255, + expected: "-w", + }, + { + name: "page ID 256", + pageID: 256, + expected: "AAE", + }, + } - t.Run("already-canonical-wiki", func(t *testing.T) { - input := "/wiki/spaces/SPACE/pages/12345/PageName" - assert.Equal(t, input, normalizeConfluenceWebUIPath(input)) - }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := encodeTinyLinkID(tt.pageID) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGenerateTinyLink(t *testing.T) { + tests := []struct { + name string + baseURL string + pageID string + expected string + wantErr bool + }{ + { + name: "cloud URL with trailing slash", + baseURL: "https://example.atlassian.net/wiki/", + pageID: "5645697027", + expected: "https://example.atlassian.net/wiki/x/A4CCUAE", + wantErr: false, + }, + { + name: "cloud URL without trailing slash", + baseURL: "https://example.atlassian.net/wiki", + pageID: "5645697027", + expected: "https://example.atlassian.net/wiki/x/A4CCUAE", + wantErr: false, + }, + { + name: "server URL", + baseURL: "https://confluence.example.com", + pageID: "98319", + expected: "https://confluence.example.com/x/D4AB", + wantErr: false, + }, + { + name: "invalid page ID", + baseURL: "https://example.atlassian.net/wiki", + pageID: "not-a-number", + expected: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := GenerateTinyLink(tt.baseURL, tt.pageID) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +// encodeTinyLinkIDPerl32 implements the Perl algorithm from Atlassian docs +// using pack("L", $pageID) which is 32-bit little-endian. +// This is used to validate our implementation matches the documented algorithm. +func encodeTinyLinkIDPerl32(id uint32) string { + buf := make([]byte, 4) + binary.LittleEndian.PutUint32(buf, id) + encoded := base64.StdEncoding.EncodeToString(buf) + + var result strings.Builder + for _, c := range encoded { + switch c { + case '=': + continue + case '/': + result.WriteByte('-') + case '+': + result.WriteByte('_') + default: + result.WriteRune(c) + } + } + s := result.String() + // Perl strips trailing 'A' chars (which are base64 for zero bits) + s = strings.TrimRight(s, "A") + return s +} + +func TestEncodeTinyLinkIDMatchesPerl(t *testing.T) { + // Validate that our implementation matches the Perl algorithm from: + // https://support.atlassian.com/confluence/kb/how-to-programmatically-generate-the-tiny-link-of-a-confluence-page + testIDs := []uint32{1, 255, 256, 65535, 98319, 98320} + + for _, id := range testIDs { + goResult := encodeTinyLinkID(uint64(id)) + perlResult := encodeTinyLinkIDPerl32(id) + assert.Equal(t, perlResult, goResult, "ID %d should match Perl implementation", id) + } +} + +func TestEncodeTinyLinkIDLargeIDs(t *testing.T) { + // Test large page IDs (> 32-bit) which are common in Confluence Cloud + // These exceed Perl's pack("L") but our implementation handles them + largeID := uint64(5645697027) // User's actual page ID from the issue + result := encodeTinyLinkID(largeID) + assert.NotEmpty(t, result) + assert.Equal(t, "A4CCUAE", result) + + // Verify the result is a valid URL-safe base64-like string + assert.Regexp(t, `^[A-Za-z0-9_-]+$`, result) }