After migration wiki from on-prem to Attlassian Cloud we’ve got all old links broken. Here is a workaround.
1) Overview
A Cloudflare Worker that:
- Extracts
pageId
from…/pages/viewpage.action?pageId=…
- Decodes tiny links
/x/<code>
→pageId
- Optionally parses
/display/<SPACEKEY>/<TITLE>
- Looks up
SPACEKEY + Title
in KV (PAGES
) and 301‑redirects to Atlassian Cloud search:https://<new-wiki>.atlassian.net/wiki/search?text=<SPACEKEY> <Title>
- Enforces an ASN allowlist (e.g., AS12345) on the production host to prevent titles enumeration
- Uses Workers KV with one record per Confluence page:
key = pid:<CONTENTID>
→value = {"s":"<SPACEKEY>","t":"<Title>"}
- Scopes routes only to legacy Confluence paths
2) Prerequisites
- Cloudflare
<your domain>
zone access; DNS record for<old-wiki.yourdomain.com>
iis Proxied (orange cloud) - Windows with PowerShell 5.1+ or 7+
- CSV export with columns:
CONTENTID,SPACEKEY,TITLE
- Cloudflare API token with Workers KV Storage: Read & Edit
3) Export mapping from MySQL → CSV
-- Use the returned folder from SHOW VARIABLES LIKE 'secure_file_priv';
SELECT 'CONTENTID','SPACEKEY','TITLE'
UNION ALL
SELECT c.CONTENTID, s.SPACEKEY, c.TITLE
FROM CONTENT c
JOIN SPACES s ON s.SPACEID = c.SPACEID
WHERE c.CONTENTTYPE='PAGE' AND c.PREVVER IS NULL
INTO OUTFILE '/var/lib/mysql-files/confluence_pages.csv'
FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '"' ESCAPED BY '"'
LINES TERMINATED BY '\n';
4) Cloudflare setup
4.1 Create an API token (UI)
Dashboard → My Profile → API Tokens → Create Token → Custom
Permissions: Workers KV Storage: Edit and Read. Copy the token to $tok
.
4.2 Find your Account ID (PowerShell)
$tok = "<YOUR_API_TOKEN>"
$acct = (Invoke-RestMethod -Method Get -Uri "https://api.cloudflare.com/client/v4/accounts" -Headers @{ Authorization = "Bearer $tok" }).result[0].id
$acct
(If you have multiple accounts, select by .name
.)
4.3 Create a KV namespace (UI)
Workers & Pages → Storage → KV → Create a namespace
Name it e.g. confluence-pages
. Note the Namespace ID → $ns
.
You can also list namespaces via API:
$acct = "<ACCOUNT_ID>"
$tok = "<YOUR_API_TOKEN>"
$r = Invoke-RestMethod -Method Get -Uri "https://api.cloudflare.com/client/v4/accounts/$acct/storage/kv/namespaces?per_page=100" -Headers @{ Authorization = "Bearer $tok" }
$r.result | Select-Object id,title
4.4 Convert CSV → KV JSON + split (PowerShell)
# Paths
$csv = "C:\temp\confluence_pages.csv"
$outDir = "C:\temp\kv_chunks"
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
# Convert CSV to KV bulk JSON (kv_pages.json)
$data = Import-Csv -Path $csv
$records = foreach($row in $data){
$cid = "$($row.CONTENTID)".Trim()
$sp = "$($row.SPACEKEY)".Trim()
$ttl = "$($row.TITLE)".Trim()
if($cid -and $sp){ [pscustomobject]@{ key = "pid:$cid"; value = (ConvertTo-Json @{ s=$sp; t=$ttl } -Compress ) } }
}
$kvJson = Join-Path $outDir 'kv_pages.json'
$records | ConvertTo-Json -Compress | Out-File -Encoding utf8 $kvJson
# Split to ≤9k per file
$chunk = 9000
$i=0
$all = (Get-Content $kvJson -Raw | ConvertFrom-Json)
for($ofs=0; $ofs -lt $all.Count; $ofs+=$chunk){
$i++
$part = $all[$ofs..([Math]::Min($ofs+$chunk-1,$all.Count-1))]
($part | ConvertTo-Json -Compress) | Out-File -Encoding utf8 (Join-Path $outDir ("kv_batch_{0:D2}.json" -f $i))
}
Write-Host "Created $i chunk file(s) in $outDir"
4.5 Upload KV JSON batches (PowerShell, REST API)
Endpoint: PUT /accounts/{account_id}/storage/kv/namespaces/{namespace_id}/bulk
$acct = "<ACCOUNT_ID>"
$namespaceId = "<NAMESPACE_ID>"
$tok = "<TOKEN>"
$dir = "C:\xfer\kv"
$uri = "https://api.cloudflare.com/client/v4/accounts/$acct/storage/kv/namespaces/$namespaceId/bulk"
$hdr = @{ Authorization = "Bearer $tok"; "Content-Type" = "application/json" }
function Invoke-KVBulkPut {
param([string]$Path, [int]$MaxRetries=10)
$gap=20
for ($i=0; $i -le $MaxRetries; $i++) {
try {
Write-Host "Uploading $(Split-Path $Path -Leaf) (try $($i+1))..."
$resp = Invoke-WebRequest -Method Put -Uri $uri -Headers $hdr -InFile $Path -TimeoutSec 600
Write-Host "OK: $($resp.StatusCode)"
Start-Sleep -Seconds $gap
return
} catch {
$res = $_.Exception.Response
$code = $res.StatusCode.value__
$retryAfter = 0
try { $retryAfter = [int]$res.Headers["Retry-After"] } catch {}
if ($code -eq 429 -or $code -ge 500) {
$wait = if ($retryAfter -gt 0) { [Math]::Min($retryAfter, 600) } else { [Math]::Min(300, (5 * [math]::Pow(2,$i))) }
Write-Warning "Rate-limited/server error ($code). Waiting $wait s, then retry…"
Start-Sleep -Seconds $wait
} else { throw }
}
}
throw "Failed after $MaxRetries retries: $Path"
}
Get-ChildItem $dir -Filter "kv_batch_*.json" | Sort-Object Name | ForEach-Object {
Invoke-KVBulkPut -Path $_.FullName
}
Spot‑check a key:
$acct = "<ACCOUNT_ID>"
$namespaceId = "<NAMESPACE_ID>"
$tok = "<TOKEN>"
$key = [uri]::EscapeDataString('pid:100401234')
Invoke-RestMethod -Method Get -Uri "https://api.cloudflare.com/client/v4/accounts/$acct/storage/kv/namespaces/$namespaceId/values/$key" -Headers @{ Authorization = "Bearer $tok" }
5) Create and configure the Worker (UI)
5.1 Create Worker (UI)
Workers & Pages → Create application → Worker → Create Worker
Name: wiki-redirect
→ Deploy → open Edit code
5.2 Worker code (includes ASN guard)
Click to view Worker JS script code
/**
* Cloudflare Worker — legacy Confluence URL → Atlassian Cloud search
*
* What it does
* ------------
* - Handles ALL of these legacy shapes:
* 1) /pages/viewpage.action?pageId=12345678 -> extract pageId
* 2) /x/abCD (Confluence tiny link) -> decode to pageId
* 3) /display/SPACEKEY/Title+With%2C+Encoding -> parse space/title
* 4) Anything else -> use last path segment
*
* - If it can resolve a numeric pageId, it looks up {"s":"SPACEKEY","t":"Title"}
* from KV (binding name: PAGES) where keys are "pid:<pageId>".
* On hit → redirects with ?text=<Title>&spaces=<SPACEKEY>
*
* - Redirects to:
* https://<new-wiki>.atlassian.net/wiki/search?text=<Title>&spaces=<SPACEKEY>
*
* Setup (once)
* ------------
* 1) In Cloudflare Dashboard:
* Workers & Pages → Storage → KV → Create namespace (e.g. "confluence-pages")
* Then bind it to this Worker as KV binding **PAGES**
*
* 2) Your KV should contain:
* key: pid:<CONTENTID>
* value: {"s":"<SPACEKEY>","t":"<Title>"} (JSON string)
*
* 3) Route example (wrangler.toml or UI Triggers → Routes):
* <old-wiki.yourdomain.com>/display/*
* <old-wiki.yourdomain.com>/x/*
* <old-wiki.yourdomain.com>/pages/viewpage.action*
*
* Testing
* -------
* curl -I "https://<oldwiki.yourdomain.com>/pages/viewpage.action?pageId=123456789"
* curl -I "https://<oldwiki.yourdomain.com>/x/abCD"
*
* Tuning
* ------
* - Set PERMANENT = false while testing (302), then true for final (301).
* - Set env.DEBUG = "1" (Worker variable) to enable basic logging.
*/
const PERMANENT = true; // true=301 permanent, false=302 temporary while testing
// ---------- Helpers ----------
// Decode Confluence "tiny link" (/x/<code>) to numeric pageId.
// Tiny link is URL-safe base64 of a 4-byte LITTLE-ENDIAN pageId, no padding.
function tinyToId(tiny) {
// Revert URL-safe alphabet to standard Base64
let s = tiny.replace(/-/g, "+").replace(/_/g, "/");
// Confluence may drop padding; if length%4==1, they sometimes pad with 'A'
if (s.length % 4 === 1) s += "A";
while (s.length % 4) s += "=";
const bin = Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
if (bin.length < 4) return null;
// 4-byte little-endian to unsigned 32-bit integer
const id =
(bin[0] | (bin[1] << 8) | (bin[2] << 16) | ((bin[3] << 24) >>> 0)) >>> 0;
return id;
}
// Extract pageId from known URL forms: ?pageId=... or /x/<tiny>
function extractPageId(u) {
const url = new URL(u);
// 1) /pages/viewpage.action?pageId=123
const pid = url.searchParams.get("pageId");
if (pid && /^\d+$/.test(pid)) return Number(pid);
// 2) /x/kxRAD (allow trailing slash)
const tinyMatch = url.pathname.match(/^\/x\/([A-Za-z0-9_-]+)\/?$/);
if (tinyMatch) {
const id = tinyToId(tinyMatch[1]);
if (typeof id === "number" && isFinite(id)) return id;
}
return null;
}
// Extract /display/<SPACE>/<TITLE...> if available
function extractDisplaySpaceAndTitle(u) {
const url = new URL(u);
const m = url.pathname.match(/^\/display\/([^/]+)\/(.+)$/);
if (!m) return null;
const spacekey = safeDecodePlusThenUri(m[1]); // typically already plain
const rawTitle = m[2];
// Titles in old URLs often have '+' for spaces and % encodings
const title = safeDecodePlusThenUri(rawTitle);
return { spacekey, title };
}
// Decode helper: replace '+' with space, then decodeURIComponent safely
function safeDecodePlusThenUri(s) {
try {
// '+' represented spaces in many legacy Confluence links
const withSpaces = s.replace(/\+/g, " ");
// decode %XX sequences (if any); if invalid, keep as-is
return decodeURIComponent(withSpaces);
} catch {
return s.replace(/\+/g, " ");
}
}
// Build the Atlassian Cloud search URL:
// - text: page title
// - spaces: optional space key (e.g., "~username" or "SPACEKEY")
function buildSearchUrl(text, spaces) {
const url = new URL("https://<new-wiki>.atlassian.net/wiki/search");
url.searchParams.set("text", text);
if (spaces) url.searchParams.set("spaces", spaces);
return url.toString();
}
// Basic logger controlled by env.DEBUG == "1"
function log(env, ...args) {
if (env && env.DEBUG === "1") {
// eslint-disable-next-line no-console
console.log(...args);
}
}
// ---------- Worker ----------
export default {
/**
* @param {Request} request
* @param {{ PAGES: KVNamespace, DEBUG?: string }} env // bind KV as PAGES
* @param {ExecutionContext} ctx
*/
async fetch(request, env, ctx) {
try {
const url = new URL(request.url);
// this block of code limits CF worker to respond to request coming from just certain AS (IP ranges)
const asn = request.cf?.asn;
const allowedASN = new Set([12345, 67890,]); // your ASNs
if (!allowedASN.has(asn)) {
return new Response("Forbidden", { status: 403 });
}
//
// 1) Best path: resolve a numeric pageId (from ?pageId= or /x/<tiny>) and hit KV
const pageId = extractPageId(request.url);
if (pageId != null) {
const kvKey = `pid:${pageId}`;
// Add null check for env.PAGES to prevent runtime error
if (env && env.PAGES) {
const rec = await env.PAGES.get(kvKey, { type: "json" }); // { s: SPACEKEY, t: Title }
log(env, "KV lookup", kvKey, "→", rec ? "HIT" : "MISS");
if (rec && rec.s && rec.t) {
// Use title in ?text= and space key in ?spaces=
const target = buildSearchUrl(rec.t, rec.s);
return Response.redirect(target, PERMANENT ? 301 : 302);
}
}
}
// 2) Next best: if we have /display/<SPACE>/<TITLE>, use both directly
const display = extractDisplaySpaceAndTitle(request.url);
if (display) {
const target = buildSearchUrl(display.title, display.spacekey);
return Response.redirect(target, PERMANENT ? 301 : 302);
}
// 3) Fallback: last non-empty path segment decoded
const parts = url.pathname.split("/").filter(Boolean);
const last = parts.length ? parts[parts.length - 1] : "";
const query = safeDecodePlusThenUri(last);
const target = buildSearchUrl(query || ""); // text-only fallback
return Response.redirect(target, PERMANENT ? 301 : 302);
} catch (err) {
// Ultimate safety net: send them to search home with a TEMP redirect
// (Don't 301 unexpected errors, to avoid caching a mistake.)
return Response.redirect(
"https://<new-wiki>.atlassian.net/wiki/search",
302
);
}
},
};
5.3 Bind KV in the Worker (UI)
Worker → Settings → Variables/Bindings → KV Namespace bindings → Add binding
Binding name: PAGES
5.4 Add Routes (UI)
<old-wiki.yourdomain.com>/display/*
<old-wiki.yourdomain.com>/x/*
<old-wiki.yourdomain.com>/pages/viewpage.action*
6) Test (Windows/PowerShell)
# pageId form (should show a 301 with Location containing ?text=...)
curl.exe -I "https://<old-wiki.yourdomain.com>/pages/viewpage.action?pageId=123456789" | Select-String "^HTTP|^Location"
# tiny link
curl.exe -I "https://<old-wiki.yourdomain.com>/x/abCD" | Select-String "^HTTP|^Location"
# display form
curl.exe -I "https://<old-wiki.yourdomain.com>/display/SPACEKEY/Some+Title" | Select-String "^HTTP|^Location"
KV value check:
$acct = "<ACCOUNT_ID>"
$namespaceId = "<NAMESPACE_ID>"
$tok = "<TOKEN>"
$key=[uri]::EscapeDataString('pid:123456789')
Invoke-RestMethod -Method Get -Uri "https://api.cloudflare.com/client/v4/accounts/$acct/storage/kv/namespaces/$namespaceId/values/$key" -Headers @{ Authorization = "Bearer $tok" }
7) Optional edge protections (UI)
WAF Custom Rule (Block unless ASN=123456)
Expression:
(http.host eq "old-wiki.yourdomain.com") and (
http.request.uri.path starts_with "/display/" or
http.request.uri.path starts_with "/x/" or
http.request.uri.path cstarts_with "/pages/viewpage.action"
) and not (ip.src.asnum eq 1640123456)
Action: Block (or Managed Challenge).
Zone Lockdown: allow only corporate IP ranges for those three URL patterns.
8) Troubleshooting
- Redirect goes to bare search (no
?text=
) → KV binding missing or key absent. Fix bindings; verify specificpid:<id>
exists. /x/
falls back to?text=code
→ batch with that pageId not uploaded; decode tiny → pageId and verify key.- HTTP 403 from Worker → request came from outside allowed IP range (AS).
- HTTP 429 during upload → API rate limits; retry with backoff; add pauses between batches.
- No effect on routes → ensure DNS is Proxied and routes are attached.
9) Maintenance & rollback
- Update mapping: regenerate JSON for changed pages and re‑upload (bulk put overwrites).
- Turn off quickly: remove the three routes in Triggers → Routes (or switch the Worker to return 302 to search home).
- Audit logs: Workers → Logs and Security → WAF for blocked attempts.
Appendix — PowerShell Confluence Wiki tiny‑link decoder
function Get-ConfPageIdFromTiny([string]$code){
$s = $code.Replace('-', '+').Replace('_','/')
switch ($s.Length % 4) { 1{$s+='A'} 2{$s+='=='} 3{$s+='='} }
$b = [Convert]::FromBase64String($s)
[BitConverter]::ToUInt32($b,0)
}