Files
pad/internal/models
xarmian 94d35509a4 feat: share links with hardened security, anonymous access, and analytics (#88)
* feat: share links with hashed tokens and /s/{token} route

Add share_links and share_link_views tables with CRUD API and
anonymous resolution route (TASK-421).

Data model:
- share_links: token_hash (SHA-256), target_type/id, permission,
  password_hash, expires_at, max_views, require_auth, view tracking
- share_link_views: per-view records with fingerprint/user tracking

Token security:
- 192-bit entropy (crypto/rand), URL-safe base64 encoding
- SHA-256 hashed at rest, raw token returned only once on creation
- Generic 404 for invalid tokens (no info leakage)
- /api/v1/s/ exempt from auth middleware for anonymous access

API endpoints:
- POST /items/{slug}/share-links — create item share link
- POST /collections/{coll}/share-links — create collection share link
- GET /items/{slug}/share-links — list share links for item
- GET /collections/{coll}/share-links — list for collection
- DELETE /share-links/{id} — revoke share link
- GET /s/{token} — resolve share link, return shared content

D8: Anonymous users are ALWAYS read-only. View count and unique
viewers tracked on each resolution.

* feat: anonymous share page + share link management UI

Add minimal-chrome share link viewer page and share link CRUD in
the share dialog (TASK-422 + TASK-425).

Share page (/s/{token}):
- New SvelteKit route at /s/[token] for anonymous viewing
- Renders item (title, fields, markdown content) or collection
  (name, item list) with no app chrome (no sidebar/topbar)
- Handles require_auth links with "Sign in to view" prompt
- Root layout bypasses auth checks for /s/ routes
- "Powered by Pad" footer

Share dialog updates:
- "Share links" section below existing grants
- Create/list/revoke share links for items and collections
- Copy-to-clipboard for share URLs
- Newly created links highlighted with "only shown once" notice
- View count and auth-required badges

API client:
- ShareLink type added
- shareLinks.* methods for CRUD
- share.get(token) for anonymous resolution

* feat: share link constraints + view analytics

Add password protection, expiry, max views, and view history
endpoints for share links (TASK-423 + TASK-424).

Constraints (TASK-423):
- CreateShareLink accepts ShareLinkOptions: password, expires_at,
  max_views, require_auth, restrict_to_email
- Password hashed with bcrypt, verified on /s/{token} resolution
- Password-protected links return {require_password: true} prompt
- Expiry and max_views already validated by ValidateShareLink

Analytics (TASK-424):
- GET /share-links/{id}/views returns view history with fingerprint,
  user ID, and timestamp
- Response includes total_views, unique_viewers, last_viewed_at
- View history stored per-view in share_link_views table

* fix: harden share links — XSS, access control, data leakage, and UX gaps

- Sanitize rendered markdown with DOMPurify before {@html} injection (XSS)
- Force require_auth=true when restrict_to_email is set (access bypass)
- Reject malformed non-empty JSON bodies with 400 instead of failing open
- Return public DTOs on share endpoints to prevent leaking internal IDs,
  creator info, assignees, schemas, and other sensitive fields
- Enforce max_views atomically via conditional UPDATE to prevent races
- Fix collection share rendering: read items from top-level response key
  and map ref/status fields correctly
- Add password prompt UI and X-Share-Password header support so
  password-protected links can actually be unlocked by the frontend

* fix: follow-up hardening for share links

- Sanitize catch fallback in rendered markdown (XSS edge case if marked throws)
- Remove query-string password fallback; accept only X-Share-Password header
  to avoid leaking passwords in logs, browser history, and referrers
- Return 500 on ListItems DB failure instead of swallowing as empty collection
- Normalize restrict_to_email with ToLower/TrimSpace on create and compare
- Fix malformed JSON check for chunked bodies (ContentLength == -1)
  by checking for io.EOF instead of ContentLength > 0
- Remove internal share_link.id from public DTO responses
- Use clientIP(r) helper for consistent fingerprinting instead of raw
  X-Forwarded-For which is spoofable and includes port in RemoteAddr
- Distinguish DB errors from not-found in share link delete handler

* fix: final hardening pass for share links

- Move auth/email gate before password check to prevent unauthenticated
  callers from probing passwords and burning bcrypt CPU
- Wrap view recording (counter increment, unique-viewer accounting, view
  insert) in a single transaction so a failed insert rolls back the
  consumed view count instead of silently losing it
- Add X-Share-Password to CORS AllowedHeaders so cross-origin
  deployments can send the custom header without preflight rejection
- Validate expires_at (RFC3339) and max_views (> 0) on share link
  creation; return 400 for invalid constraints instead of creating
  immediately-unusable links
- Cap view-history endpoint limit to 1000 to prevent unbounded queries
2026-04-11 16:40:18 -04:00
..
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00