diff --git a/apps/hook/server/index.ts b/apps/hook/server/index.ts index 1b8c553..aae8e9e 100644 --- a/apps/hook/server/index.ts +++ b/apps/hook/server/index.ts @@ -36,6 +36,7 @@ import { handleAnnotateServerReady, } from "@plannotator/server/annotate"; import { getGitContext, runGitDiff } from "@plannotator/server/git"; +import { generateRemoteShareUrl, formatSize } from "@plannotator/server/share-url"; // Embed the built HTML at compile time // @ts-ignore - Bun import attribute for text @@ -79,7 +80,23 @@ if (args[0] === "review") { sharingEnabled, shareBaseUrl, htmlContent: reviewHtmlContent, - onReady: handleReviewServerReady, + onReady: async (url, isRemote, port) => { + handleReviewServerReady(url, isRemote, port); + + if (isRemote && sharingEnabled && rawPatch) { + try { + const shareUrl = await generateRemoteShareUrl(rawPatch, shareBaseUrl); + const size = formatSize(new TextEncoder().encode(shareUrl).length); + process.stderr.write( + `\n Open this link on your local machine to review changes:\n` + + ` ${shareUrl}\n\n` + + ` (${size} — diff only, annotations added in browser)\n\n` + ); + } catch { + // Share URL generation failed silently + } + } + }, }); // Wait for user feedback @@ -126,7 +143,23 @@ if (args[0] === "review") { sharingEnabled, shareBaseUrl, htmlContent: planHtmlContent, - onReady: handleAnnotateServerReady, + onReady: async (url, isRemote, port) => { + handleAnnotateServerReady(url, isRemote, port); + + if (isRemote && sharingEnabled) { + try { + const shareUrl = await generateRemoteShareUrl(markdown, shareBaseUrl); + const size = formatSize(new TextEncoder().encode(shareUrl).length); + process.stderr.write( + `\n Open this link on your local machine to annotate:\n` + + ` ${shareUrl}\n\n` + + ` (${size} — document only, annotations added in browser)\n\n` + ); + } catch { + // Share URL generation failed silently + } + } + }, }); // Wait for user feedback @@ -174,8 +207,23 @@ if (args[0] === "review") { sharingEnabled, shareBaseUrl, htmlContent: planHtmlContent, - onReady: (url, isRemote, port) => { + onReady: async (url, isRemote, port) => { handleServerReady(url, isRemote, port); + + if (isRemote && sharingEnabled) { + try { + const shareUrl = await generateRemoteShareUrl(planContent, shareBaseUrl); + const size = formatSize(new TextEncoder().encode(shareUrl).length); + process.stderr.write( + `\n Open this link on your local machine to review the plan:\n` + + ` ${shareUrl}\n\n` + + ` (${size} — plan only, annotations added in browser)\n\n` + ); + } catch { + // Share URL generation failed — the local server is still usable + // via port forwarding as a fallback. + } + } }, }); diff --git a/apps/paste-worker/index.ts b/apps/paste-worker/index.ts new file mode 100644 index 0000000..004abba --- /dev/null +++ b/apps/paste-worker/index.ts @@ -0,0 +1,141 @@ +/** + * 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); + + if (allowed.includes(origin) || allowed.includes('*')) { + return { + ...BASE_CORS_HEADERS, + 'Access-Control-Allow-Origin': origin, + }; + } + + // Don't send CORS headers for disallowed origins — the browser will + // block the response anyway, and partial headers are misleading. + return {}; +} + +/** + * 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 only the id — the client constructs the full URL using its own + // shareBaseUrl, so self-hosted deployments work without reconfiguration. + return Response.json( + { 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/server/index.ts b/packages/server/index.ts index ea3b84d..c91fb87 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -35,6 +35,8 @@ import { import { getRepoInfo } from "./repo"; import { detectProjectName } from "./project"; +import { generateRemoteShareUrl, formatSize } from "./share-url"; + // Re-export utilities export { isRemoteSession, getServerPort } from "./remote"; export { openBrowser } from "./browser"; diff --git a/packages/server/package.json b/packages/server/package.json index 4a3d3fc..a351c93 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -13,7 +13,8 @@ "./browser": "./browser.ts", "./storage": "./storage.ts", "./git": "./git.ts", - "./repo": "./repo.ts" + "./repo": "./repo.ts", + "./share-url": "./share-url.ts" }, "files": [ "*.ts" diff --git a/packages/server/share-url.ts b/packages/server/share-url.ts new file mode 100644 index 0000000..531efa3 --- /dev/null +++ b/packages/server/share-url.ts @@ -0,0 +1,66 @@ +/** + * Server-side share URL generation for remote sessions + * + * Generates a share.plannotator.ai URL from plan content so remote users + * can open the review in their local browser without port forwarding. + * + * Uses the same deflate-raw + base64url encoding as the client-side + * sharing utilities in @plannotator/ui. + */ + +const DEFAULT_SHARE_BASE = "https://share.plannotator.ai"; + +// Intentional subset of the canonical SharePayload in @plannotator/ui/utils/sharing.ts. +// Only `p` (plan) is populated server-side; annotations are added in the browser. +// If the canonical format changes, this must be updated to match. +interface SharePayload { + p: string; + a: []; +} + +/** + * Generate a share URL from plan markdown content. + * + * Returns the full hash-based URL. For remote sessions, this lets the + * user open the plan in their local browser without any backend needed. + */ +export async function generateRemoteShareUrl( + plan: string, + shareBaseUrl?: string +): Promise { + const base = shareBaseUrl || DEFAULT_SHARE_BASE; + + // Build minimal payload (no annotations at server startup) + const payload: SharePayload = { p: plan, a: [] }; + const json = JSON.stringify(payload); + const byteArray = new TextEncoder().encode(json); + + // Compress using deflate-raw (same as client-side) + const stream = new CompressionStream("deflate-raw"); + const writer = stream.writable.getWriter(); + writer.write(byteArray); + writer.close(); + + const buffer = await new Response(stream.readable).arrayBuffer(); + const compressed = new Uint8Array(buffer); + + // Convert to base64url — use a loop instead of spread to avoid + // RangeError on large plans (spread has a ~65K argument limit). + let binary = ""; + for (let i = 0; i < compressed.length; i++) { + binary += String.fromCharCode(compressed[i]); + } + const base64 = btoa(binary); + const hash = base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); + + return `${base}/#${hash}`; +} + +/** + * Format byte size as human-readable string + */ +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + return kb < 100 ? `${kb.toFixed(1)} KB` : `${Math.round(kb)} KB`; +} 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 */}