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
102
page/link.go
102
page/link.go
@ -2,12 +2,14 @@ package page
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kovetskiy/mark/confluence"
|
"github.com/kovetskiy/mark/confluence"
|
||||||
@ -200,8 +202,9 @@ func parseLinks(markdown string) []markdownLink {
|
|||||||
return links
|
return links
|
||||||
}
|
}
|
||||||
|
|
||||||
// getConfluenceLink build (to be) link for Confluence, and tries to verify from
|
// getConfluenceLink builds a stable Confluence tiny link for the given page.
|
||||||
// API if there's real link available
|
// Tiny links use the format {baseURL}/x/{encodedPageID} and are immune to
|
||||||
|
// Cloud-specific URL variations like /ex/confluence/<cloudId>/wiki/...
|
||||||
func getConfluenceLink(
|
func getConfluenceLink(
|
||||||
api *confluence.API,
|
api *confluence.API,
|
||||||
space, title string,
|
space, title string,
|
||||||
@ -211,69 +214,68 @@ func getConfluenceLink(
|
|||||||
return "", karma.Format(err, "api: find page")
|
return "", karma.Format(err, "api: find page")
|
||||||
}
|
}
|
||||||
if page == nil {
|
if page == nil {
|
||||||
// Without a page ID there is no stable way to produce
|
|
||||||
// /wiki/spaces/<space>/pages/<id>/<name>.
|
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confluence Cloud web UI URLs can be returned either as a path ("/wiki/..." or
|
tiny, err := GenerateTinyLink(api.BaseURL, page.ID)
|
||||||
// "/ex/confluence/<cloudId>/wiki/...") or as a full absolute URL.
|
|
||||||
absolute, err := makeAbsoluteConfluenceWebUIURL(api.BaseURL, page.Links.Full)
|
|
||||||
if err != nil {
|
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) {
|
// GenerateTinyLink generates a Confluence tiny link from a page ID.
|
||||||
if webui == "" {
|
// The algorithm converts the page ID to a little-endian 32-bit byte array,
|
||||||
return "", nil
|
// base64-encodes it, and applies URL-safe transformations.
|
||||||
}
|
// Format: {baseURL}/x/{encodedID}
|
||||||
|
//
|
||||||
u, err := url.Parse(webui)
|
// 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 {
|
if err != nil {
|
||||||
return "", err
|
return "", fmt.Errorf("invalid page ID %q: %w", pageID, 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
encoded := encodeTinyLinkID(id)
|
||||||
baseURL = strings.TrimSuffix(baseURL, "/")
|
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||||
if !strings.HasPrefix(path, "/") {
|
|
||||||
path = "/" + path
|
return baseURL + "/x/" + encoded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := baseURL + path
|
// encodeTinyLinkID encodes a page ID into the Confluence tiny link format.
|
||||||
if u.RawQuery != "" {
|
// This is the core algorithm extracted for testability.
|
||||||
result += "?" + u.RawQuery
|
func encodeTinyLinkID(id uint64) string {
|
||||||
}
|
// Pack as little-endian. Use 8 bytes to support large page IDs,
|
||||||
if u.Fragment != "" {
|
// but the base64 trimming will remove unnecessary trailing zeros.
|
||||||
result += "#" + u.Fragment
|
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]
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeConfluenceWebUIPath rewrites Confluence Cloud "experience" URLs
|
return result.String()
|
||||||
// ("/ex/confluence/<cloudId>/wiki/..."), to canonical wiki paths ("/wiki/...").
|
|
||||||
func normalizeConfluenceWebUIPath(path string) string {
|
|
||||||
if path == "" {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
re := regexp.MustCompile(`^/ex/confluence/[^/]+(/wiki/.*)$`)
|
|
||||||
match := re.FindStringSubmatch(path)
|
|
||||||
if len(match) == 2 {
|
|
||||||
return match[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
package page
|
package page
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@ -52,15 +55,152 @@ func TestParseLinks(t *testing.T) {
|
|||||||
assert.Equal(t, len(links), 8)
|
assert.Equal(t, len(links), 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeConfluenceWebUIPath(t *testing.T) {
|
func TestEncodeTinyLinkID(t *testing.T) {
|
||||||
t.Run("confluence-cloud-experience-prefix", func(t *testing.T) {
|
// Test cases for the tiny link encoding algorithm.
|
||||||
input := "/ex/confluence/cloud-id/wiki/spaces/SPACE/pages/12345/PageName"
|
// The algorithm: little-endian bytes -> base64 -> URL-safe transform
|
||||||
expected := "/wiki/spaces/SPACE/pages/12345/PageName"
|
tests := []struct {
|
||||||
assert.Equal(t, expected, normalizeConfluenceWebUIPath(input))
|
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) {
|
for _, tt := range tests {
|
||||||
input := "/wiki/spaces/SPACE/pages/12345/PageName"
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
assert.Equal(t, input, normalizeConfluenceWebUIPath(input))
|
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