Diagram Sharing & Encryption
Zero-knowledge link sharing, client-side AES-128-GCM encryption, snapshot isolation, deduplication, and rate limiting.
FileKit Sketch includes an end-to-end encrypted sharing engine. Anyone can share a point-in-time snapshot of their whiteboard using a single URL without creating an account or exposing unencrypted diagrams to any server.
1. Zero-Knowledge Security Architecture
Diagram sharing in FileKit Sketch is 100% Zero-Knowledge:
- In-Browser Encryption:
- The browser generates a cryptographically random 128-bit symmetric key (
AES-128-GCM) and a 12-byte IV using the Web Crypto API. - The drawing payload is compressed with native
CompressionStream('deflate-raw')and encrypted locally.
- The browser generates a cryptographically random 128-bit symmetric key (
- Key Stays in
#Hash Fragment:- The generated link has the format
https://sketch.../playground#json=<id>,<key>. - RFC Standard: By browser HTTP specification, URL fragments following
#are never transmitted over the network or logged in server access logs.
- The generated link has the format
- Server Sees Only Scrambled Noise:
- Neither our servers nor Upstash Redis ever have access to the decryption key. Only users who possess the complete link can decrypt and view the canvas snapshot.
2. Snapshot Isolation & Local Edits
A shared link represents an immutable point-in-time snapshot taken at the exact moment the Share button was clicked.
Edits never overwrite the link: If someone opens a shared link and draws or edits the canvas, their changes are saved only to their local browser's localStorage. The original shared link remains completely unchanged.
To publish updates or revisions, simply click Share again to generate a new snapshot link.
3. Dual-Routing Architecture
The engine automatically selects the optimal link strategy based on diagram complexity:
| Diagram Size | Routing Mode | URL Format | Server Storage | Link Lifespan |
|---|---|---|---|---|
| Small / Medium ( 1,800 chars) | Pure Client Deflate | #s=<base64url> | 0 Server Calls (100% Offline) | Permanent |
| Large / Complex (50 – 500+ shapes) | Encrypted Cloud Shortlink | #json=<id>,<key> | Upstash Redis (Ciphertext only) | 7-Day Retention |
If Upstash is unreachable or the user is rate-limited, the system automatically falls back to the #s= client link so sharing never fails.
4. Intelligent Content Fingerprinting (Deduplication)
Re-clicking the Share button on an unchanged diagram will not spam Redis or consume user storage quotas:
- Transient UI Stripping:
- Selecting a shape, moving the cursor, or zooming does not alter the drawing data. The engine strips
isSelected,isHovered, andisEditingflags before hashing.
- Selecting a shape, moving the cursor, or zooming does not alter the drawing data. The engine strips
- Structural Fingerprint:
- A fingerprint of the sanitized elements is compared against the last generated share result.
- Instant Cache Return:
- If no structural changes (shapes, positions, text, colors) occurred, the modal returns the existing link in 0ms without initiating any network calls.
5. Opening a Shared Diagram
When a user visits a shared URL in another browser, Sketch detects the incoming snapshot and prompts them with a confirmation dialog:
- Replace Current Canvas: Replaces the canvas with the shared snapshot, updates
localStorage, and triggerszoomToFit(). The previous drawing can be restored anytime with Undo (Ctrl + Z). - Insert Below Existing: Keeps the user's active drawing untouched and appends the shared snapshot 80px below the existing elements.
- Empty Canvas: If the board has 0 elements, the shared snapshot loads directly without prompting.
6. Abuse Prevention & Quotas
To protect infrastructure from bots, scrapers, and denial-of-service attempts:
- Payload Size Ceiling: Requests exceeding 500 KB are rejected before processing.
- Tier 1 Anti-DDoS Rate Limit:
@upstash/ratelimitenforces a maximum of 5 share requests per 10 seconds per salted IP hash (SHA-256(ip:salt)). - Tier 2 User Quota (FIFO): An atomic Redis Lua script caps active links to 5 links per hour per user. Generating a 6th link automatically evicts the oldest entry (
LPOP+DEL). - Graceful Fallback: If rate limits are reached, the UI displays a subtle cooldown notice and switches seamlessly to generating a self-contained
#s=client link.
7. Implementing Sharing in Your Own App
The @filekit-studio/sketch npm package is 100% client-side and unopinionated. It does not bundle proprietary database connections or vendor locks. You have complete freedom over how diagrams are shared in your application.
The <SketchEditor /> Sharing Contract
<SketchEditor /> provides three built-in touchpoints for sharing:
onShare?: () => void: Fired when the user clicks the Share button in the top bar. Simply passing this callback automatically mounts the Share button.uiConfig={{ showShareButton: true }}: Explicit toggle for the Share button.- Default:
falsewhenonShareis omitted;trueautomatically wheneveronShareis passed. - Strict Invariant: If you explicitly set
showShareButton: true, theonSharecallback is required. If omitted, a console warning is logged and clicking the button will throw a runtime error.
- Default:
controller.importElements(elements, { sourceType: 'share' }): Prompts the user with the built-in smart confirmation modal ("Replace Current Canvas" vs "Insert Below Existing") and automatically fits the camera withzoomToFit().
<SketchEditor
controllerRef={controllerRef}
onShare={handleShare} // Automatically mounts the Share button in the top bar
/>Option A: Pure Client-Side URL Hash Sharing (Zero Backend, 100% Free)
This is the fastest way to add sharing. It compresses the canvas drawing directly into the browser URL hash using native Web APIs (CompressionStream and DecompressionStream).
- Zero backend required: No servers, databases, or API keys.
- Works offline: Ideal for static sites, documentation playgrounds, and internal tools.
- Instant: 0ms latency with permanent link longevity.
"use client";
import React, { useRef, useEffect } from "react";
import dynamic from "next/dynamic";
import type { SketchController, SketchBoardElement } from "@filekit-studio/sketch";
const SketchEditor = dynamic(
() => import("@filekit-studio/sketch").then((mod) => mod.SketchEditor),
{ ssr: false }
);
// 1. Native browser Deflate-Raw compression to URL-safe Base64
async function compressToHash(elements: SketchBoardElement[]): Promise<string> {
const json = JSON.stringify(elements);
const stream = new Blob([new TextEncoder().encode(json)])
.stream()
.pipeThrough(new CompressionStream("deflate-raw"));
const buffer = await new Response(stream).arrayBuffer();
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// 2. Native browser DecompressionStream from URL-safe Base64
async function decompressFromHash(base64Url: string): Promise<SketchBoardElement[]> {
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const padLen = (4 - (base64.length % 4)) % 4;
const padded = base64 + "=".repeat(padLen);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const stream = new Blob([bytes])
.stream()
.pipeThrough(new DecompressionStream("deflate-raw"));
const json = await new Response(stream).text();
return JSON.parse(json) as SketchBoardElement[];
}
export function WhiteboardWithShare() {
const controllerRef = useRef<SketchController | null>(null);
// 3. User clicks the top-bar Share button
const handleShare = async () => {
if (!controllerRef.current) return;
const elements = controllerRef.current.getElements();
if (!elements || elements.length === 0) {
controllerRef.current.showToast?.("Canvas is empty. Draw something before sharing!");
return;
}
try {
const hash = await compressToHash(elements);
const shareUrl = `${window.location.origin}${window.location.pathname}#s=${hash}`;
await navigator.clipboard.writeText(shareUrl);
controllerRef.current.showToast?.("Share link copied to clipboard!");
} catch (err) {
console.error("Failed to generate share link:", err);
}
};
// 4. Automatically detect and load shared diagrams when visiting the link
useEffect(() => {
const hash = window.location.hash;
if (!hash.startsWith("#s=")) return;
const payload = hash.slice(3);
if (!payload) return;
decompressFromHash(payload)
.then((elements) => {
// Automatically triggers the smart confirmation modal ("Replace" vs "Insert Below")
controllerRef.current?.importElements?.(elements, { sourceType: "share" });
})
.catch((err) => {
console.error("Failed to decompress shared diagram:", err);
});
}, []);
return (
<div className="w-full h-screen">
<SketchEditor
controllerRef={controllerRef}
onShare={handleShare}
uiConfig={{ showShareButton: true }}
/>
</div>
);
}Option B: Cloud Database Shortlinks (Your Own Backend)
If you want clean, compact URLs (e.g., https://yourapp.com/board/d_a9f1b2) or need to support complex whiteboards with hundreds of detailed elements, persist the snapshot in your own backend (PostgreSQL, Supabase, Upstash Redis, AWS S3, or MongoDB).
1. Backend Route Handler (Next.js App Router)
Create an API endpoint to store and retrieve snapshots:
import { NextRequest, NextResponse } from "next/server";
// Replace with your ORM / client: Prisma, Drizzle, Supabase, or Redis
import { redis } from "@/lib/redis";
import { nanoid } from "nanoid";
// POST /api/share - Save snapshot
export async function POST(req: NextRequest) {
try {
const { elements } = await req.json();
if (!elements || !Array.isArray(elements)) {
return NextResponse.json({ error: "Invalid diagram payload" }, { status: 400 });
}
const id = nanoid(10);
// Persist with a 30-day expiration (or permanent)
await redis.set(`share:${id}`, JSON.stringify(elements), { ex: 60 * 60 * 24 * 30 });
return NextResponse.json({ id });
} catch (err) {
return NextResponse.json({ error: "Failed to store snapshot" }, { status: 500 });
}
}
// GET /api/share?id=abc123xyz - Retrieve snapshot
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "Missing snapshot ID" }, { status: 400 });
}
const data = await redis.get(`share:${id}`);
if (!data) {
return NextResponse.json({ error: "Diagram not found or expired" }, { status: 404 });
}
const elements = typeof data === "string" ? JSON.parse(data) : data;
return NextResponse.json({ elements });
}2. Frontend React Integration
Wire onShare to post the diagram and load it back on mount:
"use client";
import React, { useRef, useEffect } from "react";
import dynamic from "next/dynamic";
import type { SketchController } from "@filekit-studio/sketch";
const SketchEditor = dynamic(
() => import("@filekit-studio/sketch").then((mod) => mod.SketchEditor),
{ ssr: false }
);
interface CloudWhiteboardProps {
shareId?: string; // Optional route param, e.g. /board/[id]
}
export function CloudWhiteboard({ shareId }: CloudWhiteboardProps) {
const controllerRef = useRef<SketchController | null>(null);
// 1. Share button handler: post to your API
const handleShare = async () => {
if (!controllerRef.current) return;
const elements = controllerRef.current.getElements();
try {
const res = await fetch("/api/share", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ elements }),
});
if (!res.ok) throw new Error("Share request failed");
const { id } = (await res.json()) as { id: string };
const shareUrl = `${window.location.origin}/board/${id}`;
await navigator.clipboard.writeText(shareUrl);
controllerRef.current.showToast?.("Cloud share link copied to clipboard!");
} catch (err) {
console.error("Failed to share diagram:", err);
}
};
// 2. Load snapshot if opened via /board/[id]
useEffect(() => {
if (!shareId) return;
fetch(`/api/share?id=${encodeURIComponent(shareId)}`)
.then((res) => {
if (!res.ok) throw new Error("Snapshot not found");
return res.json();
})
.then((data: { elements: unknown }) => {
if (Array.isArray(data.elements)) {
controllerRef.current?.importElements?.(data.elements, { sourceType: "share" });
}
})
.catch((err) => console.error("Error loading snapshot:", err));
}, [shareId]);
return (
<div className="w-full h-screen">
<SketchEditor
controllerRef={controllerRef}
onShare={handleShare}
uiConfig={{ showShareButton: true }}
/>
</div>
);
}Option C: Adding Zero-Knowledge Encryption (Enterprise & Privacy)
If your app requires zero-knowledge confidentiality (where even your database administrators cannot view diagrams):
- Encrypt on Client: Use
crypto.subtle.generateKeyandcrypto.subtle.encryptwithAES-128-GCMbefore sending bytes to/api/share. - Store Only Ciphertext: Send the encrypted ciphertext envelope to your backend.
- Decryption Key Stays in Hash: Place the symmetric key strictly after the
#fragment in the URL (e.g.,/board/ID#KEY). - RFC Invariant: URL fragments after
#are strictly client-side and never reach your web server or get logged in access logs, ensuring 100% zero-knowledge security.