From 5315e73fbce15dc7b08151c087b6d68886d01a1b Mon Sep 17 00:00:00 2001 From: Gunit Bindal Date: Thu, 26 Feb 2026 20:23:29 +0530 Subject: [PATCH 1/4] Add short URL sharing via paste service to fix URL truncation on Slack/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: #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/ 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 --- apps/paste-worker/index.ts | 135 +++++++++++++++++++++++++ apps/paste-worker/package.json | 13 +++ apps/paste-worker/wrangler.toml | 12 +++ packages/editor/App.tsx | 6 ++ packages/ui/components/ExportModal.tsx | 93 +++++++++++++---- packages/ui/hooks/useSharing.ts | 92 ++++++++++++++++- packages/ui/utils/sharing.ts | 92 ++++++++++++++++- 7 files changed, 424 insertions(+), 19 deletions(-) create mode 100644 apps/paste-worker/index.ts create mode 100644 apps/paste-worker/package.json create mode 100644 apps/paste-worker/wrangler.toml diff --git a/apps/paste-worker/index.ts b/apps/paste-worker/index.ts new file mode 100644 index 0000000..f293065 --- /dev/null +++ b/apps/paste-worker/index.ts @@ -0,0 +1,135 @@ +/** + * Plannotator Paste Worker + * + * Lightweight KV-backed paste service for short share URLs. + * Stores compressed plan data and returns short IDs. + * + * Routes: + * POST /api/paste - Store compressed data, returns { id, url } + * GET /api/paste/:id - Retrieve stored compressed data + * + * Deploy this worker to paste.plannotator.ai (or your own domain). + * Self-hosters can point PLANNOTATOR_PASTE_URL to their own instance. + */ + +interface Env { + PASTE_KV: KVNamespace; + /** Comma-separated allowed origins, defaults to share.plannotator.ai + localhost */ + ALLOWED_ORIGINS?: string; +} + +const BASE_CORS_HEADERS = { + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + 'Access-Control-Max-Age': '86400', +}; + +function getAllowedOrigins(env: Env): string[] { + if (env.ALLOWED_ORIGINS) { + return env.ALLOWED_ORIGINS.split(',').map(o => o.trim()); + } + return ['https://share.plannotator.ai', 'http://localhost:3001']; +} + +function corsHeaders(request: Request, env: Env): Record { + const origin = request.headers.get('Origin') ?? ''; + const allowed = getAllowedOrigins(env); + const headers: Record = { ...BASE_CORS_HEADERS }; + + if (allowed.includes(origin) || allowed.includes('*')) { + headers['Access-Control-Allow-Origin'] = origin; + } + + return headers; +} + +/** + * Generate a short URL-safe ID (8 chars ≈ 48 bits of entropy). + * Uses Web Crypto so it works in the edge runtime. + */ +function generateId(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + return Array.from(bytes, b => chars[b % chars.length]).join(''); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const cors = corsHeaders(request, env); + + // Handle CORS preflight + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: cors }); + } + + // POST /api/paste — store compressed plan payload, return short ID + if (url.pathname === '/api/paste' && request.method === 'POST') { + try { + const body = (await request.json()) as { data?: unknown }; + + if (!body.data || typeof body.data !== 'string') { + return Response.json( + { error: 'Missing or invalid "data" field' }, + { status: 400, headers: cors } + ); + } + + // 512 KB limit — generous for compressed plan data (most plans are <50 KB) + if (body.data.length > 524_288) { + return Response.json( + { error: 'Payload too large (max 512 KB compressed)' }, + { status: 413, headers: cors } + ); + } + + const id = generateId(); + + // Store with 90-day TTL + await env.PASTE_KV.put(`paste:${id}`, body.data, { + expirationTtl: 90 * 24 * 60 * 60, + }); + + return Response.json( + { id, url: `https://share.plannotator.ai/p/${id}` }, + { status: 201, headers: cors } + ); + } catch { + return Response.json( + { error: 'Failed to store paste' }, + { status: 500, headers: cors } + ); + } + } + + // GET /api/paste/:id — retrieve stored compressed data + const pasteMatch = url.pathname.match(/^\/api\/paste\/([A-Za-z0-9]{6,16})$/); + if (pasteMatch && request.method === 'GET') { + const id = pasteMatch[1]; + const data = await env.PASTE_KV.get(`paste:${id}`); + + if (!data) { + return Response.json( + { error: 'Paste not found or expired' }, + { status: 404, headers: cors } + ); + } + + return Response.json( + { data }, + { + headers: { + ...cors, + 'Cache-Control': 'public, max-age=3600', + }, + } + ); + } + + return Response.json( + { error: 'Not found. Valid paths: POST /api/paste, GET /api/paste/:id' }, + { status: 404, headers: cors } + ); + }, +}; diff --git a/apps/paste-worker/package.json b/apps/paste-worker/package.json new file mode 100644 index 0000000..727f251 --- /dev/null +++ b/apps/paste-worker/package.json @@ -0,0 +1,13 @@ +{ + "name": "@plannotator/paste-worker", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241218.0", + "wrangler": "^3.99.0" + } +} diff --git a/apps/paste-worker/wrangler.toml b/apps/paste-worker/wrangler.toml new file mode 100644 index 0000000..23c3240 --- /dev/null +++ b/apps/paste-worker/wrangler.toml @@ -0,0 +1,12 @@ +name = "plannotator-paste" +main = "index.ts" +compatibility_date = "2024-12-01" + +[[kv_namespaces]] +binding = "PASTE_KV" +# Run `wrangler kv:namespace create PASTE_KV` to get your IDs and fill them in. +id = "REPLACE_WITH_KV_NAMESPACE_ID" +preview_id = "REPLACE_WITH_PREVIEW_KV_NAMESPACE_ID" + +[vars] +ALLOWED_ORIGINS = "https://share.plannotator.ai,http://localhost:3001" diff --git a/packages/editor/App.tsx b/packages/editor/App.tsx index 4db80ac..d70a7bd 100644 --- a/packages/editor/App.tsx +++ b/packages/editor/App.tsx @@ -417,6 +417,9 @@ const App: React.FC = () => { isLoadingShared, shareUrl, shareUrlSize, + shortShareUrl, + isGeneratingShortUrl, + shortUrlError, pendingSharedAnnotations, sharedGlobalAttachments, clearPendingSharedAnnotations, @@ -1221,6 +1224,9 @@ const App: React.FC = () => { onClose={() => { setShowExport(false); setInitialExportTab(undefined); }} shareUrl={shareUrl} shareUrlSize={shareUrlSize} + shortShareUrl={shortShareUrl} + isGeneratingShortUrl={isGeneratingShortUrl} + shortUrlError={shortUrlError} annotationsOutput={annotationsOutput} annotationCount={annotations.length} taterSprite={taterMode ? : undefined} diff --git a/packages/ui/components/ExportModal.tsx b/packages/ui/components/ExportModal.tsx index 4ae98d2..5970ac5 100644 --- a/packages/ui/components/ExportModal.tsx +++ b/packages/ui/components/ExportModal.tsx @@ -15,6 +15,12 @@ interface ExportModalProps { onClose: () => void; shareUrl: string; shareUrlSize: string; + /** Short share URL from the paste service (empty string when unavailable) */ + shortShareUrl?: string; + /** Whether the short URL is currently being generated */ + isGeneratingShortUrl?: boolean; + /** Error from the last short URL generation attempt (empty string = no error) */ + shortUrlError?: string; annotationsOutput: string; annotationCount: number; taterSprite?: React.ReactNode; @@ -34,6 +40,9 @@ export const ExportModal: React.FC = ({ onClose, shareUrl, shareUrlSize, + shortShareUrl = '', + isGeneratingShortUrl = false, + shortUrlError = '', annotationsOutput, annotationCount, taterSprite, @@ -44,7 +53,7 @@ export const ExportModal: React.FC = ({ }) => { const defaultTab = initialTab || (sharingEnabled ? 'share' : 'annotations'); const [activeTab, setActiveTab] = useState(defaultTab); - const [copied, setCopied] = useState(false); + const [copied, setCopied] = useState<'short' | 'full' | 'annotations' | false>(false); const [saveStatus, setSaveStatus] = useState>({ obsidian: 'idle', bear: 'idle' }); const [saveErrors, setSaveErrors] = useState>({}); @@ -72,10 +81,10 @@ export const ExportModal: React.FC = ({ const isObsidianReady = obsidianSettings.enabled && effectiveVaultPath.trim().length > 0; const isBearReady = bearSettings.enabled; - const handleCopyUrl = async () => { + const handleCopy = async (text: string, which: 'short' | 'full' | 'annotations') => { try { - await navigator.clipboard.writeText(shareUrl); - setCopied(true); + await navigator.clipboard.writeText(text); + setCopied(which); setTimeout(() => setCopied(false), 2000); } catch (e) { console.error('Failed to copy:', e); @@ -83,13 +92,7 @@ export const ExportModal: React.FC = ({ }; const handleCopyAnnotations = async () => { - try { - await navigator.clipboard.writeText(annotationsOutput); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (e) { - console.error('Failed to copy:', e); - } + await handleCopy(annotationsOutput, 'annotations'); }; const handleDownloadAnnotations = () => { @@ -225,22 +228,70 @@ export const ExportModal: React.FC = ({ {/* Tab content */} {activeTab === 'share' && sharingEnabled ? (
+ {/* Short URL — primary copy target when available */} + {shortShareUrl ? ( +
+ +
+ (e.target as HTMLInputElement).select()} + /> + +
+

+ Short link — safe for Slack, email, and messaging apps. Expires in 90 days. +

+
+ ) : isGeneratingShortUrl ? ( +
+ + + + Generating short link... +
+ ) : null} + + {/* Full hash URL — always available as fallback */}