Secure Preview + improved external Share — design
Date: 2026-07-14 · Status: approved (user), autopilot build
Depends on: forensic watermarking engine (stego sidecar), egress chokepoint egressProtectPDF, existing sharing context.
Goal
Two stacked deliverables:
-
Phase 1 — Secure Preview (the vulnerability fix). Today every preview surface —
internal document preview, public share links, the public-signing page, the external-sign
review — ships the whole PDF to the browser; the canvas viewer is a UX deterrent, not
a barrier (the file sits in the network tab). Worse: the plain internal preview serves
unwatermarked bytes (only?sign=1marks), and signed PDFs are served clean on
every channel (watermark deliberately skipped to protect/ByteRange). Fix: previews
become server-rendered watermarked page images. Real PDF bytes leave the system on
exactly ONE path: download (already watermarked). The protection context's own design
anticipated this ("View is delivered via the separate view-session path (page/tile)") —
this build completes it. -
Phase 2 — improved Share system for external parties, built on Phase 1: Manage+-gated
Share tab, protection modes (anyone-with-link / password), always-preview (never a file),
and per-access logging.
Locked decisions (user)
- Never the real thing: previews are server-rendered images. The real file is only
emitted by the download path (which watermarks). Internal users included — an internal
user who wants bytes downloads (watermarked, ledgered). - Module-gated: the whole secure-preview machinery only engages when the
watermarking module is licensed. Without it, everything behaves exactly as today
(no rasterization infra, no behavior change) — "if no stegano module, no need this much effort." - Share tab access: Manage or higher on the document.
- Share protection modes v1: anyone-with-link and password; model extensibly for
OTP-verified-email + domain allowlist later. - Log link access for security purposes (every attempt, including denials).
Phase 1 — Secure Preview
Rasterizer (extract sidecar, NOT the stego sidecar)
deploy/extract-sidecar/ (FastAPI) already ships pypdfium2 (used for OCR page renders)
and is deployed/healthy. It gains two endpoints (multipart in, like /extract):
POST /pdf/info— body:file. Returns{"pages": N, "sizes": [[w_pt,h_pt], ...]}.POST /pdf/page— body:file,page(0-based),scale(default ~1.8 ≈ 130dpi).
Returnsimage/pngof that page.
Rationale: the stego sidecar also has PDFium, but its files carry a parallel agent's
uncommitted WIP — the extract sidecar is clean, already in the air-gapped deploy, and
rasterization is generic (input is the already-watermarked PDF; no stego knowledge needed).
Go: preview sessions (go/internal/httpapi)
A preview session = short-lived, in-memory, capability-token-addressed handle to ONE
prepared (watermarked or signed-clean) PDF:
previewSessionStore(new filepreview_session.go):map[token]{pdf []byte, pages int, docID, version, expiresAt}+ per-page rendered-PNG LRU memo. TTL ~15 min, sweep on access,
global byte cap (~256 MB, LRU eviction). Token = 32-byte crypto/rand base64url (bearer
capability; unguessable, single-document, short-lived — no further auth on page fetch).- Mint flow (per surface): load doc+version bytes → office/HTML→PDF convert if needed
(existings.office) →egressProtectPDF(...)with the surface's channel (internal =
ChannelView, share =ChannelShare, public-sign =ChannelShare) →POST /pdf/info→
store session → return{secure: true, sid, pages}. Signed PDFs come back clean from
the chokepoint (by design) and are rasterized as-is — images of a signed doc leak no bytes.
Every mint writes an issuance row (existing ledger) = internal views become ledgered too. - Page fetch:
GET /api/v1/preview-session/{sid}/page/{n}→ PNG (renders on demand via
sidecar, memoizes). One shared endpoint for all surfaces; the sid IS the authorization. - Non-PDF images (png/jpg…): pass through the session too (single "page", re-encoded PNG by
the sidecar? No — served as stored; images were never the PDF-bytes problem. Keep current
behavior for image MIME types.)
Surface wiring (all module-gated)
| Surface | Module ON | Module OFF |
|---|---|---|
Internal preview meta (GET /documents/{id}/secure-preview?version=) |
mints session (ChannelView), {secure:true,sid,pages} |
{secure:false} → FE uses today's byte path |
Internal byte preview (GET .../preview) |
PDFs: always watermarked via egressProtectPDF (no more clean-preview bypass; keeps mobile app working — its exposure becomes identical to download) | unchanged (as today) |
Share (GET /api/v1/shared/{token}/preview) |
410-style refusal (secure_preview_required) — viewer uses image mode |
unchanged |
Share meta (GET /api/v1/shared/{token}/meta) + unlock |
mints session (ChannelShare) after gates | {secure:false} (viewer falls back to byte preview) |
Share raw (GET /shared/{token} SharedAccess) |
PDFs refused (points to viewer); non-PDF unchanged | unchanged |
Public-sign preview (GET /public/sign/{token}/preview) |
refused; new meta endpoint mints session (ChannelShare, attributed to signer email) | unchanged |
Sign placement (?sign=1 fetch) |
placement modal uses secure-preview image mode (it only needs page images + pt dimensions, returned by /pdf/info) |
unchanged |
| Download | unchanged — the ONE real-byte path, watermarked (signed docs clean by design) | unchanged |
FE (web/)
RestrictedPDFViewer gains an image mode: each surface first calls its meta endpoint;
if {secure:true} it renders <img> pages (/preview-session/{sid}/page/{n}, lazy, fitted,
same chrome/watermark-overlay UX); else it falls back to the existing pdf.js canvas path.
PlaceSignatureModal computes placement from /pdf/info page sizes (pt) + rendered images —
coordinates stay 1:1 (scale factor known).
Phase 2 — improved Share
Schema (migration 00097)
ALTER TABLE share_links ADD COLUMN protection text NOT NULL DEFAULT 'link'; -- 'link'|'password' (later: 'otp','allowlist')
ALTER TABLE share_links ADD COLUMN password_hash text; -- argon2id (auth's Argon2Hasher), only for protection='password'
CREATE TABLE share_access_log (
id uuid PRIMARY KEY,
link_id uuid NOT NULL REFERENCES share_links(id) ON DELETE CASCADE,
document_id text NOT NULL,
at timestamptz NOT NULL DEFAULT now(),
outcome text NOT NULL, -- viewed|denied_password|expired|revoked|exhausted|blocked_dlp|not_found_version|deleted
ip text NOT NULL DEFAULT '',
user_agent text NOT NULL DEFAULT '',
viewer text NOT NULL DEFAULT '' -- future: verified email for otp mode
);
CREATE INDEX share_access_log_link_idx ON share_access_log (link_id, at DESC);
Backend
- Create/list gates → Manage+:
server.goshare routes bumpAccessReadWrite→AccessManage
(create + list + revoke consistency: revoke also becomes Manage+ via requireAccess on the
document, replacing the globaldocument.shareperm mismatch). - Caller's effective access exposed:
GET /documents/{id}response gainsmy_access
("read"/"readwrite"/"editor"/"manage") so the FE can hide the Share tab below Manage. - Create accepts
{protection: 'link'|'password', password?, ttl_seconds, max_access?};
password argon2id-hashed; validation: password mode requires ≥6 chars. - Public flow:
GET /api/v1/shared/{token}/meta→{doc_title, requires_password, secure}(no consume).POST /api/v1/shared/{token}/unlock→ body{password?}→ gates
(Active + DLP + password verify, rate-limited per token+IP reusing the pubSignLimiter
pattern) → consume access → mint preview session →{sid, pages}. Every attempt —
success or any denial — appends ashare_access_logrow (IP + UA). - Access log read:
GET /shares/{shareID}/accesses(Manage+ on the document) for the FE. - Share links become preview-only:
modestays in the schema but create hardcodes/rejects
download(FE already only mints view);SharedAccessraw-file streaming for PDFs is
retired under the module (Phase 1 table above).
FE
- ShareSection v2 (Share tab,
DocumentDetailView): tab hidden below Manage
(my_access); protection-mode selector (Anyone with link / Password) + password input;
expiry; created-URL copy; per-link row expands to the access log (time, outcome, IP, UA). - SharedViewPage v2: calls
/meta; ifrequires_passwordshows a password gate; on
unlock renders the image viewer (no more direct byte fetch). i18n en/id.
Extensibility notes (not built now)
protection='otp': viewer enters email → OTP (reuse esign local-OTP infra) →viewer
column gets the verified email → per-viewer watermark attribution on the minted session.protection='allowlist': comma-list column of emails/domains gating who may request OTP.- Mobile app: still uses the byte preview (now always watermarked, exposure == download);
follow-up to switch it to image mode.
Security invariants (the point of it all)
- With watermarking licensed, no surface emits clean PDF bytes except signed docs on
download/signed-copy (their signature IS the integrity control) — and signed docs no
longer leak via preview at all (images only). - Preview = images. Download = watermarked bytes + issuance row. Nothing else emits bytes.
- Every share access attempt is logged with outcome + IP + UA; every preview mint is
ledgered as an issuance. - Module off = today's behavior exactly (zero new infra in the hot path).
Build order
- Sidecar
/pdf/info+/pdf/page(+ tests curl-able) — deployable independently. - Go rasterizer client + preview-session store + shared page endpoint.
- Internal preview: meta endpoint + always-watermark byte fallback + FE image mode
(RestrictedPDFViewer + PlaceSignatureModal). - Share/public-sign: meta/unlock endpoints + byte-path refusals + FE viewer updates.
- Phase 2 migration 00097 + share create/gates/log + ShareSection v2 + SharedViewPage v2.
- Deploy (update.sh), e2e: module-on image flow (all surfaces), password mode, access log,
byte-endpoint refusals; verify module-off fallback intact (config toggle on a throwaway).