feat: implement GenerateTinyLink function and associated tests for Confluence tiny link generation

Signed-off-by: Nikolai Emil Damm <ndam@tv2.dk>
This commit is contained in:
Nikolai Emil Damm 2026-01-05 12:57:07 +01:00 committed by Manuel Rüger
parent ef560d095c
commit 6d5a9fba90
2 changed files with 201 additions and 59 deletions

View File

@ -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/<cloudId>/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/<space>/pages/<id>/<name>.
return "", nil
}
// Confluence Cloud web UI URLs can be returned either as a path ("/wiki/..." or
// "/ex/confluence/<cloudId>/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/<cloudId>/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()
}

View File

@ -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)
}