I needed to share a confidential document with 12 people. I wanted to know who opened it, how long they spent on each page, and whether anyone forwarded the link. I also wanted the document to self-destruct after a week.
DocSend does all of this. It costs $45/month per user, your documents live on their servers, and there's no self-hosting option. For a sensitive investor deck, that felt backwards. Paying a third party to host the exact document you're trying to control access to.
So I built CloakShare: an open-source document sharing platform with tracking, watermarks, link expiry, and self-hosting via Docker. Here's how the technical decisions played out.
The Central Problem: Preventing Casual Copying
Let me be clear upfront. DRM is a losing battle against a determined adversary. Someone with OBS and a screen recorder will always win. CloakShare doesn't try to solve that unsolvable problem.
What it does solve is casual redistribution. The person who screenshots your deck and pastes it in a Slack channel. The person who forwards your link to someone who shouldn't have it. The person who copy-pastes your pricing page into a competitor's email.
The design goal is: make unauthorized sharing inconvenient enough that most people won't bother, and traceable enough that you'll know if they do.
Why Canvas Rendering
The first design decision was how to render documents in the browser. The obvious approaches are:
- Embedded PDF viewer - The browser's native PDF renderer. Problem: the content is fully selectable, downloadable via browser controls, and exists as text nodes in the DOM.
- HTML rendering - Convert PDF pages to HTML. Problem: all content is in the DOM, trivially copyable, and scrapable.
- Image rendering - Convert pages to images and display them. Better, but images are still easily saved via right-click or devtools.
CloakShare uses HTML5 Canvas rendering. Each page is drawn as pixels on a <canvas> element using PDF.js as the rendering engine. The document content never exists as selectable text or as a discrete image file in the DOM. It's pixel data in a canvas buffer.
async function renderPage(pdf, pageNumber, canvas, scale) {
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
canvas.height = viewport.height;
canvas.width = viewport.width;
const context = canvas.getContext('2d');
await page.render({
canvasContext: context,
viewport: viewport
}).promise;
// Overlay watermark directly on the same canvas
applyWatermark(context, viewport, viewerEmail);
}
The watermark is rendered directly onto the same canvas context as the document. There is no separate watermark layer that can be removed via devtools. To capture the document, you must capture the watermark too.
Dynamic Watermarking
The watermark contains:
- The viewer's email address (from the access link)
- A timestamp
- A semi-transparent diagonal pattern across the full page
This is drawn on every page render, including when the user resizes the browser, scrolls, or switches tabs and returns. The watermark text is rendered at varying angles and positions using a seeded random layout, making it difficult to remove programmatically even from a screenshot.
function applyWatermark(ctx, viewport, email) {
const text = `${email} | ${new Date().toISOString()}`;
ctx.save();
ctx.globalAlpha = 0.08;
ctx.font = '14px monospace';
ctx.fillStyle = '#000000';
// Tile watermark across the entire canvas
const step = 200;
for (let y = 0; y < viewport.height; y += step) {
for (let x = -viewport.width; x < viewport.width * 2; x += step) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(-0.35);
ctx.fillText(text, 0, 0);
ctx.restore();
}
}
ctx.restore();
}
The globalAlpha of 0.08 makes the watermark nearly invisible during normal viewing but clearly legible if someone cranks up the contrast on a screenshot.
HLS Video Streaming
CloakShare supports video content alongside documents. The challenge with video is similar: you don't want to serve a single downloadable MP4 file.
The solution is HTTP Live Streaming (HLS). Videos are transcoded into small .ts segments (typically 6 seconds each), with an .m3u8 playlist manifest. Each segment URL includes a one-time token that expires after use.
# Simplified HLS flow
Upload video > FFmpeg transcode > Generate segments
> Generate manifest
> Store in S3-compatible storage
Viewer requests manifest > Validate access token
> Return manifest with tokenized segment URLs
Viewer requests segment > Validate segment token (single-use)
> Stream segment
> Invalidate token
This means there is no single URL that downloads the full video. A viewer's browser fetches segments individually as playback progresses. Adaptive bitrate selection means the quality adjusts to bandwidth without exposing a high-res master file.
Analytics Architecture
Every viewer interaction generates an event:
- Page viewed (with timestamp and duration)
- Scroll position (sampled every 2 seconds)
- Document opened / closed
- Link accessed (with IP and user-agent)
Events are batched client-side and sent to the backend every 5 seconds (or on page close via navigator.sendBeacon). The backend aggregates these into per-viewer, per-page metrics.
The analytics dashboard shows:
- Who opened the link and when
- Time spent per page (color-coded heatmap)
- Total view duration
- Completion rate (what percentage of pages were viewed)
- Device and location data
This turns document sharing from "send and hope" into an observable interaction.
Self-Hosting With Docker
The full stack runs in three containers:
# docker-compose.yml (simplified)
services:
app:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:password@db:5432/cloakshare
- S3_BUCKET=cloakshare-docs
- S3_ENDPOINT=http://minio:9000
db:
image: postgres:16-alpine
minio:
image: minio/minio
command: server /data
docker compose up and you're running. Replace MinIO with actual S3 or any S3-compatible storage for production. The app container handles both the Next.js frontend and the Node.js API.
What I'd Do Differently
Start with canvas rendering from day one. I initially tried an HTML-based approach and spent weeks on CSS-based copy protection hacks that were all trivially bypassable. Canvas was the right call. I just took too long to commit to it.
Don't build your own video transcoding pipeline. I burned two weeks on FFmpeg edge cases. If I did it again, I'd use a managed transcoding service and only self-host the streaming part.
Analytics batching is critical. My first version sent an event on every scroll. At 60fps with 12 concurrent viewers, the backend fell over. Batch and debounce everything.
The project is MIT-licensed and live at cloakshare.dev. If you've ever been frustrated by DocSend's pricing, its lack of self-hosting, or the irony of uploading confidential documents to a third party to "protect" them, take a look.
Top comments (0)