mirror of
https://github.com/kovetskiy/mark.git
synced 2026-01-22 10:47:36 +08:00
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:
parent
ef560d095c
commit
6d5a9fba90
100
page/link.go
100
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/<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()
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user