Skip to content

Add short URL sharing via paste service to fix URL truncation#188

Open
GunitBindal wants to merge 2 commits intobacknotprop:mainfrom
GunitBindal:fix/short-share-urls
Open

Add short URL sharing via paste service to fix URL truncation#188
GunitBindal wants to merge 2 commits intobacknotprop:mainfrom
GunitBindal:fix/short-share-urls

Conversation

@GunitBindal
Copy link

@GunitBindal GunitBindal commented Feb 26, 2026

Summary

Fixes #187 — Share URLs for large plans are 10-40KB+ and get truncated by Slack, WhatsApp, email clients, etc., making the team sharing feature unusable for real-world plans. Also addresses the root cause behind #142 (silent fallback to demo plan on truncated URLs).

This adds an optional paste-service backend that stores compressed plan payloads and returns short ~50-char URLs like share.plannotator.ai/p/aBcDeFgH.

What changed

New: apps/paste-worker/ — Cloudflare Worker paste service

  • POST /api/paste stores compressed plan data in KV, returns short ID
  • GET /api/paste/:id retrieves stored data
  • 90-day TTL, 512KB limit, CORS configured
  • Single file (~130 lines), zero dependencies, essentially free to run

Modified: packages/ui/utils/sharing.ts

  • Added createShortShareUrl() — POSTs compressed data to paste service (5s timeout)
  • Added loadFromPasteId() — fetches plan by paste ID (10s timeout)
  • Exported toShareableImages (was module-private, now needed by createShortShareUrl)
  • All existing functions unchanged

Modified: packages/ui/hooks/useSharing.ts

  • Added shortShareUrl, isGeneratingShortUrl, shortUrlError to hook state
  • loadFromHash() now checks for /p/<id> path pattern first, then falls back to hash
  • Auto-generates short URL with 1s debounce when annotations change
  • All existing behavior preserved

Modified: packages/ui/components/ExportModal.tsx

  • Share tab shows short URL as primary copy target when available
  • Full hash URL always visible as backup
  • Loading spinner while short URL generates
  • Amber hint when paste service is unavailable
  • Copy button tracks which URL was copied (short/full/annotations independently)

Modified: packages/editor/App.tsx

  • Passes new short URL props from useSharing to ExportModal

Design decisions

  • Fully backward compatible — hash-based URLs work exactly as before
  • Graceful degradation — if paste service is down, silently falls back to hash URLs
  • Self-host friendlyPLANNOTATOR_PASTE_URL env var overrides the paste API URL
  • No new dependencies — uses native fetch + existing compression utils
  • Portal support is automaticuseSharing hook detects /p/<id> paths, so the portal inherits short URL loading without any portal-specific changes

Deployment note

The paste worker (apps/paste-worker/) needs to be deployed separately to paste.plannotator.ai with a Cloudflare KV namespace. Until deployed, the feature degrades gracefully — users see only the existing hash-based URL.

Test plan

  • Verify existing hash-based share URLs still work unchanged
  • Verify ExportModal shows short URL when paste service is available
  • Verify ExportModal falls back to hash URL only when paste service is unavailable
  • Verify portal loads plans from /p/<id> paths
  • Verify portal still loads plans from /#<hash> paths (backward compat)
  • Verify short URLs work in Slack, WhatsApp, email (no truncation)
  • Verify Copy button independently tracks short vs full URL state
  • Deploy paste worker and test end-to-end

…k/messaging apps

Share URLs for large plans can be 10-40KB+ because the entire plan + annotations
are compressed into the URL hash. Services like Slack, WhatsApp, and Twitter
truncate these URLs, making shared plans unviewable (related: backnotprop#142).

This adds an optional paste-service backend that stores compressed payloads and
returns short ~60-char URLs (e.g. share.plannotator.ai/p/aBcDeFgH).

Changes:
- Add Cloudflare Worker paste service (apps/paste-worker/) with KV storage
  and 90-day TTL for stored plans
- Add createShortShareUrl() and loadFromPasteId() to sharing utils
- Update useSharing hook to auto-generate short URLs with 1s debounce
- Update ExportModal to show short URL as primary copy target with
  full hash URL as backup
- Portal automatically supports /p/<id> paths via useSharing hook
- Fully backward compatible: hash-based URLs continue to work unchanged
- Graceful degradation: falls back to hash URLs if paste service is unavailable
- Worker: return only { id } so client constructs URL with its own
  shareBaseUrl (fixes self-hosted deployments)
- importFromShareUrl: handle /p/<id> short URLs in addition to hash
  URLs (fixes teammate import via short links)
- Anchor /p/<id> regex with ^ to prevent false matches on nested paths
- Pass shareBaseUrl to loadFromPasteId (was always defaulting)
- replaceState preserves base path instead of hardcoding /
- Clear stale shortShareUrl immediately on debounce to prevent showing
  outdated link during the 1s delay
- Add shareBaseUrl to dependency arrays for loadFromHash and
  importFromShareUrl callbacks
- Remove unused url field fallback from paste API response parsing
@backnotprop
Copy link
Owner

backnotprop commented Feb 27, 2026

Thank you for this @GunitBindal

Am I right in assuming that this is primarily intended for self-hosters? Also, do you self-host?

Right now, the static site can be somewhat trusted because it's still open source and I'm not logging anything. There's no backend. This sorta changes the nature if I support this, but I understand why it's a great value add for the self-hoster especially.

It doesn't mean I'm not opposed to adding it for the plannotator.ai domain. I'll make sure to be a bit more transparent about when things get stored.

@GunitBindal
Copy link
Author

Hey Michael — thanks for the quick response! Also messaged you on X about this.

To answer your question: no, I don't self-host — I use plannotator.ai directly with Claude Code. The long URL problem hits me (and I imagine most users) on the hosted version itself. Any plan with real annotations produces 10-40KB+ URLs that Slack, WhatsApp, Teams, and email all silently truncate. It makes sharing with teammates essentially broken.

So while the PR is designed to also work for self-hosters, the primary motivation is fixing sharing on plannotator.ai itself.

I totally understand the trust concern — the beauty of the current design is zero backend. A few ways to keep that trust while enabling short URLs:

  1. Client-side encryption — encrypt the payload with a key embedded in the URL fragment (like share.plannotator.ai/p/aBcDeFgH#decryptionKey). The server stores opaque blobs, can't read anything. Similar to how Excalidraw+ and PrivateBin work.
  2. Auto-expiry transparency — 90-day TTL is already in the PR. Could add a visible "expires on" badge in the share UI so users know exactly when data is purged.
  3. Keep both options — the PR already falls back to hash URLs if the paste service is down. Could make it opt-in: a toggle in the share modal ("Use short URL" vs "Use direct link"). Users who want zero-backend can stick with hash URLs.
  4. Open-source the worker — the Cloudflare Worker is already in the PR under apps/paste-worker/. Fully auditable, same as the rest of the codebase.

I really love Plannotator — it's become essential to my Claude Code workflow, and this is the one thing blocking me from sharing reviews with my team daily. I'm happy to implement any of the above approaches if you point me in a direction. Also have a few more feature ideas — if you're open to receiving PRs, I'd love to contribute more.

@backnotprop
Copy link
Owner

backnotprop commented Feb 27, 2026

These are all great options - I think for large plans we can just be transparent that there will be a temporary storage of the plan to enable sharing on Plannotator.ai. I'll add this as I review the PR today.

Your feature idea for remote sessions is good - happy to take contributions it's also something I've been wanting as I do remote sessions now too.
Happy to take more contributions!!

@backnotprop
Copy link
Owner

Code review

Found 1 issue:

  1. loadFromPasteId is called with shareBaseUrl (the share portal URL, e.g. https://share.plannotator.ai) as its second argument, but the parameter expects pasteApiUrl (the paste backend URL, e.g. https://paste.plannotator.ai). These are different domains. The fetch goes to share.plannotator.ai/api/paste/<id> instead of paste.plannotator.ai/api/paste/<id>, causing all short URL loading to fail silently — both when opening a /p/<id> link and when importing a teammate's short URL. The function signature in sharing.ts documents this clearly: loadFromPasteId(pasteId: string, pasteApiUrl: string = DEFAULT_PASTE_API) where DEFAULT_PASTE_API = 'https://paste.plannotator.ai'. The call sites should either omit the second argument (letting the default apply) or pass a separate pasteApiUrl option.

const pasteId = pathMatch[1];
const payload = await loadFromPasteId(pasteId, shareBaseUrl);
if (payload) {

const pasteId = shortMatch[1];
const loaded = await loadFromPasteId(pasteId, shareBaseUrl);
if (!loaded) {

*/
export async function loadFromPasteId(
pasteId: string,
pasteApiUrl: string = DEFAULT_PASTE_API
): Promise<SharePayload | null> {
try {
const response = await fetch(`${pasteApiUrl}/api/paste/${pasteId}`, {
signal: AbortSignal.timeout(10_000),

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Share URLs are too large for Slack/messaging apps (10-40KB+ URLs get truncated)

2 participants