think
16px
820px

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:

  1. 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=1 marks), 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.

  2. 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).
    Returns image/png of 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 file preview_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
    (existing s.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.go share routes bump AccessReadWriteAccessManage
    (create + list + revoke consistency: revoke also becomes Manage+ via requireAccess on the
    document, replacing the global document.share perm mismatch).
  • Caller's effective access exposed: GET /documents/{id} response gains my_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 a share_access_log row (IP + UA).
  • Access log read: GET /shares/{shareID}/accesses (Manage+ on the document) for the FE.
  • Share links become preview-only: mode stays in the schema but create hardcodes/rejects
    download (FE already only mints view); SharedAccess raw-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; if requires_password shows 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)

  1. 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).
  2. Preview = images. Download = watermarked bytes + issuance row. Nothing else emits bytes.
  3. Every share access attempt is logged with outcome + IP + UA; every preview mint is
    ledgered as an issuance.
  4. Module off = today's behavior exactly (zero new infra in the hot path).

Build order

  1. Sidecar /pdf/info + /pdf/page (+ tests curl-able) — deployable independently.
  2. Go rasterizer client + preview-session store + shared page endpoint.
  3. Internal preview: meta endpoint + always-watermark byte fallback + FE image mode
    (RestrictedPDFViewer + PlaceSignatureModal).
  4. Share/public-sign: meta/unlock endpoints + byte-path refusals + FE viewer updates.
  5. Phase 2 migration 00097 + share create/gates/log + ShareSection v2 + SharedViewPage v2.
  6. 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).