mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
c470dcebf1
* 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/<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 * Fix critical bugs found during code review of short URL feature - 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 * Fix pasteApiUrl/shareBaseUrl confusion and add remote session share URLs Bug fix (addresses @backnotprop's code review): - useSharing hook now accepts separate `pasteApiUrl` parameter instead of incorrectly passing `shareBaseUrl` to `loadFromPasteId`. These are different domains (share portal vs paste backend). When omitted, the default `https://paste.plannotator.ai` is used correctly. - Fixed in all three call sites: loadFromHash, importFromShareUrl, and generateShortUrl (via createShortShareUrl options). Remote session share URLs (implements #192): - When running on a remote instance (SSH, devcontainer), Plannotator now generates a share.plannotator.ai URL and prints it to stderr so the user can open the plan review in their local browser. - Works for all three modes: plan review, code review, and annotate. - Uses the same deflate-raw + base64url encoding as the client. - Fails silently if URL generation fails — port forwarding still works. - New server utility: packages/server/share-url.ts * Address code review findings: fix spread limit, CORS, deps array - share-url.ts: Replace btoa(String.fromCharCode(...compressed)) with a loop to avoid RangeError on plans >65K compressed bytes - share-url.ts: Add cross-reference comment noting the server-side SharePayload is an intentional subset of the canonical UI type - paste-worker: Only return CORS headers for allowed origins; disallowed origins get no CORS headers instead of misleading partial headers - useSharing.ts: Remove unused shareBaseUrl from loadFromHash dependency array (loadFromHash only uses pasteApiUrl, not shareBaseUrl) * Restructure paste service with pluggable storage, fix consent flow and security issues Replaces monolithic paste-worker with multi-target paste-service architecture: - Core logic (handler, storage interface, CORS) separated from deployment targets - Filesystem store (self-hosted), KV store (Cloudflare), S3 stub (future) - Bun binary target + Cloudflare Worker target - Fix auto-upload removed: short URLs only created on explicit user click - Clear stale short URL state when content changes - Fix require('fs') in ESM module, add path-traversal guard in FsPasteStore - Return 400 (not 500) for malformed JSON in both targets - Fix compress() spread limit for large plans - Updated docs: self-hosting guide, sharing guide, env vars, API endpoints Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Simplify paste service: extract shared router, remove TOCTOU patterns - Extract duplicated HTTP routing from bun.ts and cloudflare.ts into handleRequest() in core/handler.ts — targets are now thin wrappers - Move ID_PATTERN regex to core (was duplicated in both targets) - Remove existsSync guard before mkdirSync({recursive:true}) in fs.ts - Remove file.exists() check before file.json() in fs.ts get() — the try/catch already handles missing files - Extract DEFAULT_SHARE_BASE constant in sharing.ts (was hardcoded twice) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Deduplicate compression, image parsing, and remote share URL logic - Create packages/shared/ with compress/decompress (single source of truth) — both @plannotator/server and @plannotator/ui import from here - Export parseShareableImages from sharing.ts, remove duplicate parseGlobalAttachments from useSharing.ts - Extract writeRemoteShareLink() helper in share-url.ts, replace 3 near-identical blocks in apps/hook/server/index.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix modulo bias in paste ID generation and remove nonexistent doc artifacts - Use rejection sampling in generateId() to eliminate modulo bias (bytes >= 248 discarded, uniform distribution over 62 chars) - Remove references to install-paste.sh and Docker image that don't exist yet; point self-hosting docs to GitHub Releases binaries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Michael Ramos <mdramos8@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
71 lines
1.8 KiB
TypeScript
71 lines
1.8 KiB
TypeScript
import { mkdirSync, readdirSync, readFileSync, unlinkSync } from "fs";
|
|
import { join, resolve } from "path";
|
|
import type { PasteStore } from "../core/storage";
|
|
|
|
interface PasteFile {
|
|
data: string;
|
|
expiresAt: number;
|
|
}
|
|
|
|
export class FsPasteStore implements PasteStore {
|
|
private resolvedDir: string;
|
|
|
|
constructor(private dataDir: string) {
|
|
mkdirSync(dataDir, { recursive: true });
|
|
this.resolvedDir = resolve(dataDir);
|
|
this.sweep();
|
|
}
|
|
|
|
private safePath(id: string): string {
|
|
const filePath = resolve(join(this.dataDir, `${id}.json`));
|
|
if (!filePath.startsWith(this.resolvedDir)) {
|
|
throw new Error("Invalid paste ID");
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
async put(id: string, data: string, ttlSeconds: number): Promise<void> {
|
|
const entry: PasteFile = {
|
|
data,
|
|
expiresAt: Date.now() + ttlSeconds * 1000,
|
|
};
|
|
await Bun.write(this.safePath(id), JSON.stringify(entry));
|
|
}
|
|
|
|
async get(id: string): Promise<string | null> {
|
|
const path = this.safePath(id);
|
|
try {
|
|
const entry: PasteFile = await Bun.file(path).json();
|
|
if (Date.now() > entry.expiresAt) {
|
|
unlinkSync(path);
|
|
return null;
|
|
}
|
|
return entry.data;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Delete expired pastes on startup */
|
|
private sweep(): void {
|
|
try {
|
|
const files = readdirSync(this.dataDir).filter((f) => f.endsWith(".json"));
|
|
const now = Date.now();
|
|
for (const file of files) {
|
|
const path = join(this.dataDir, file);
|
|
try {
|
|
const raw = readFileSync(path, "utf-8");
|
|
const entry: PasteFile = JSON.parse(raw);
|
|
if (now > entry.expiresAt) {
|
|
unlinkSync(path);
|
|
}
|
|
} catch {
|
|
// skip malformed files
|
|
}
|
|
}
|
|
} catch {
|
|
// dataDir might not exist yet
|
|
}
|
|
}
|
|
}
|