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 specific pid:<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.

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