diff --git a/app/controllers/page_limit_events_controller.rb b/app/controllers/page_limit_events_controller.rb new file mode 100644 index 0000000000..83381e6499 --- /dev/null +++ b/app/controllers/page_limit_events_controller.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class PageLimitEventsController < ApplicationController + include IframeAuthentication + + skip_before_action :verify_authenticity_token, only: [:create] + skip_before_action :authenticate_via_token! + + before_action :authenticate_from_referer + + skip_authorization_check + + SURFACES = %w[builder dashboard].freeze + PAGE_LIMIT = 120 + MAX_INTEGER_LENGTH = 10 + + def create + return head :unauthorized if current_user.blank? + + page_count = parse_integer(params[:page_count]) + file_size = params[:file_size].present? ? parse_integer(params[:file_size]) : nil + + unless valid_event_params?(page_count, file_size) + return render json: { error: 'Invalid parameters' }, status: :unprocessable_entity + end + + Rails.logger.info({ + event: 'page_limit_blocked', + page_count:, + bucket: bucket_for(page_count), + surface: params[:surface], + file_size:, + account_id: current_account&.id, + user_id: current_user&.id + }.to_json) + + head :no_content + end + + private + + def valid_event_params?(page_count, file_size) + return false if page_count.blank? || page_count <= PAGE_LIMIT + return false unless params[:surface].in?(SURFACES) + return true if params[:file_size].blank? + + file_size.present? && !file_size.negative? + end + + def bucket_for(page_count) + return '121-150' if page_count <= 150 + return '151-200' if page_count <= 200 + + '201+' + end + + def parse_integer(value) + return nil unless value.to_s.match?(/\A\d{1,#{MAX_INTEGER_LENGTH}}\z/o) + + Integer(value, exception: false) + end +end diff --git a/app/javascript/elements/dashboard_dropzone.js b/app/javascript/elements/dashboard_dropzone.js index 60cd0b91b0..cee273211c 100644 --- a/app/javascript/elements/dashboard_dropzone.js +++ b/app/javascript/elements/dashboard_dropzone.js @@ -1,10 +1,76 @@ import { target, targets, targetable } from '@github/catalyst/lib/targetable' +import { PAGE_LIMIT, PAGE_LIMIT_MESSAGE, SYNC_SCAN_LIMIT, countPdfPages, countPdfPagesSync, reportBlocked } from '../lib/pdf_page_limit_guard' const loadingIconHtml = ` ` +// Blocked uploads make no request, so there is no flash cycle: render an +// inline DOM message only. +const showBlockedMessage = (container, copy, pageCount) => { + container.querySelector(':scope > .page-limit-message')?.remove() + + const message = document.createElement('div') + + message.className = 'page-limit-message mt-2 text-sm text-red-400' + message.setAttribute('role', 'status') + message.setAttribute('aria-live', 'polite') + message.textContent = copy.replace('{page_count}', String(pageCount)) + + container.prepend(message) +} + +const onGuardedUploadChange = async (e) => { + const input = e.target instanceof Element ? e.target.closest('input[data-page-limit-guard]') : null + + if (!input || !input.files.length) { + return + } + + // Take over from the inline onchange requestSubmit; the form is re-submitted + // below when every file is within the limit. + e.preventDefault() + e.stopPropagation() + + for (const file of input.files) { + // Small files scan synchronously so allow-case uploads still dispatch in + // the change-event task; large files use the async chunked reader. + const pageCount = file.size <= SYNC_SCAN_LIMIT ? countPdfPagesSync(file) : await countPdfPages(file) + + if (pageCount && pageCount > PAGE_LIMIT) { + reportBlocked({ pageCount, surface: 'dashboard', fileSize: file.size }) + + input.value = '' + + showBlockedMessage(input.form, input.dataset.pageLimitMessage || PAGE_LIMIT_MESSAGE, pageCount) + + return + } + } + + input.form?.requestSubmit() +} + +let uploadButtonGuardInstalled = false + +// Document-level capture listener so upload-button file inputs (inline +// onchange requestSubmit) are intercepted before they submit, including on +// pages without a dashboard-dropzone element. +const installUploadButtonGuard = () => { + if (uploadButtonGuardInstalled) { + return + } + + uploadButtonGuardInstalled = true + + document.addEventListener('change', onGuardedUploadChange, true) +} + +document.addEventListener('turbo:load', installUploadButtonGuard) + +installUploadButtonGuard() + export default targetable(class extends HTMLElement { static [targets.static] = [ 'hiddenOnDrag', @@ -75,11 +141,12 @@ export default targetable(class extends HTMLElement { onDropFile = (e) => { e.preventDefault() - this.fileDropzoneLoading.classList.remove('hidden') - this.fileDropzoneLoading.previousElementSibling.classList.add('hidden') - this.fileDropzoneLoading.classList.add('opacity-50') - - this.uploadFiles(e.dataTransfer.files, '/templates_upload') + // Loading UI shows only when the guard allows the upload. + this.uploadFiles(e.dataTransfer.files, '/templates_upload', () => { + this.fileDropzoneLoading.classList.remove('hidden') + this.fileDropzoneLoading.previousElementSibling.classList.add('hidden') + this.fileDropzoneLoading.classList.add('opacity-50') + }) } onDropFolder = (e, el) => { @@ -87,54 +154,56 @@ export default targetable(class extends HTMLElement { const templateId = e.dataTransfer.getData('template_id') - if (e.dataTransfer.files.length || templateId) { - const loading = document.createElement('div') - const svg = el.querySelector('svg') - - loading.innerHTML = loadingIconHtml - loading.children[0].classList.add(...svg.classList) - - el.replaceChild(loading.children[0], svg) - el.classList.add('opacity-50') - - if (e.dataTransfer.files.length) { - const params = new URLSearchParams({ folder_name: el.innerText }).toString() + if (e.dataTransfer.files.length) { + const params = new URLSearchParams({ folder_name: el.innerText }).toString() + + this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`, () => this.showFolderLoading(el)) + } else if (templateId) { + this.showFolderLoading(el) + + const formData = new FormData() + + formData.append('name', el.innerText) + + fetch(`/templates/${templateId}/folder`, { + method: 'PUT', + redirect: 'manual', + body: formData, + headers: { + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content + } + }).finally(() => { + window.Turbo.cache.clear() + window.Turbo.visit(location.href) + }) + } + } - this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`) - } else { - const formData = new FormData() + showFolderLoading (el) { + const loading = document.createElement('div') + const svg = el.querySelector('svg') - formData.append('name', el.innerText) + loading.innerHTML = loadingIconHtml + loading.children[0].classList.add(...svg.classList) - fetch(`/templates/${templateId}/folder`, { - method: 'PUT', - redirect: 'manual', - body: formData, - headers: { - 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content - } - }).finally(() => { - window.Turbo.cache.clear() - window.Turbo.visit(location.href) - }) - } - } + el.replaceChild(loading.children[0], svg) + el.classList.add('opacity-50') } onDropTemplate = (e) => { e.preventDefault() if (e.dataTransfer.files.length) { - const loading = document.createElement('div') - loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute') - loading.innerHTML = loadingIconHtml - - e.target.appendChild(loading) - e.target.classList.add('opacity-50') - const id = e.target.href.split('/').pop() - this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`) + this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`, () => { + const loading = document.createElement('div') + loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute') + loading.innerHTML = loadingIconHtml + + e.target.appendChild(loading) + e.target.classList.add('opacity-50') + }) } } @@ -144,7 +213,25 @@ export default targetable(class extends HTMLElement { if (!this.isLoading) this.hideDraghover() } - uploadFiles (files, url) { + async uploadFiles (files, url, showLoading) { + for (const file of files) { + // Small files scan synchronously so allow-case uploads still dispatch in + // the drop-event task; large files use the async chunked reader. + const pageCount = file.size <= SYNC_SCAN_LIMIT ? countPdfPagesSync(file) : await countPdfPages(file) + + if (pageCount && pageCount > PAGE_LIMIT) { + reportBlocked({ pageCount, surface: 'dashboard', fileSize: file.size }) + + this.hideDraghover() + + showBlockedMessage(this, this.dataset.pageLimitMessage || PAGE_LIMIT_MESSAGE, pageCount) + + return + } + } + + showLoading?.() + this.isLoading = true this.form.action = url diff --git a/app/javascript/elements/file_dropzone.js b/app/javascript/elements/file_dropzone.js index 12ef253d9c..39600ae285 100644 --- a/app/javascript/elements/file_dropzone.js +++ b/app/javascript/elements/file_dropzone.js @@ -1,5 +1,21 @@ import { actionable } from '@github/catalyst/lib/actionable' import { target, targetable } from '@github/catalyst/lib/targetable' +import { PAGE_LIMIT, PAGE_LIMIT_MESSAGE, SYNC_SCAN_LIMIT, countPdfPages, countPdfPagesSync, reportBlocked } from '../lib/pdf_page_limit_guard' + +// Blocked uploads make no request, so there is no flash cycle: render an +// inline DOM message only. +const showBlockedMessage = (element, pageCount) => { + element.querySelector(':scope > .page-limit-message')?.remove() + + const message = document.createElement('div') + + message.className = 'page-limit-message mt-2 text-sm text-red-400' + message.setAttribute('role', 'status') + message.setAttribute('aria-live', 'polite') + message.textContent = (element.dataset.pageLimitMessage || PAGE_LIMIT_MESSAGE).replace('{page_count}', String(pageCount)) + + element.append(message) +} export default actionable(targetable(class extends HTMLElement { static [target.static] = [ @@ -62,7 +78,25 @@ export default actionable(targetable(class extends HTMLElement { this.classList.toggle('opacity-50') } - uploadFiles () { + async uploadFiles (files) { + if (this.dataset.pageLimitGuard) { + for (const file of files) { + // Small files scan synchronously so allow-case uploads still dispatch + // in the change-event task; large files use the async chunked reader. + const pageCount = file.size <= SYNC_SCAN_LIMIT ? countPdfPagesSync(file) : await countPdfPages(file) + + if (pageCount && pageCount > PAGE_LIMIT) { + reportBlocked({ pageCount, surface: 'dashboard', fileSize: file.size }) + + this.input.value = '' + + showBlockedMessage(this, pageCount) + + return + } + } + } + this.toggleLoading() if (this.dataset.submitOnUpload) { diff --git a/app/javascript/lib/pdf_page_limit_guard.js b/app/javascript/lib/pdf_page_limit_guard.js new file mode 100644 index 0000000000..eb9e463b6c --- /dev/null +++ b/app/javascript/lib/pdf_page_limit_guard.js @@ -0,0 +1,159 @@ +// Client-side page-count guard for PDF uploads (CP-13579). +// +// Minimal PDF parse (no pdfjs-dist): counts page objects via a latin1 regex, +// with a /Count fallback. Anything undecipherable (non-PDF, encrypted, parse +// error) returns null so callers fail open (allow the upload). +// +// Two readers share the counting core. Small files scan synchronously (sync +// XHR over an object URL) so allow-case uploads still dispatch in the +// file-input change task; larger files use the async chunked reader. + +export const PAGE_LIMIT = 120 +export const SYNC_SCAN_LIMIT = 8 * 1024 * 1024 + +// Best-practices copy for blocked dashboard uploads. The dashboard has no t() +// i18n, so this module is the single shared source (overridable per element via +// a data-page-limit-message attribute); mirrors the builder page_limit_body copy. +export const PAGE_LIMIT_MESSAGE = 'This PDF has {page_count} pages, which exceeds the 120-page limit. Split it into smaller documents under 120 pages and add fields to each document.' + +const CHUNK_SIZE = 1024 * 256 +const OVERLAP = 64 + +const PAGE_OBJECT_PATTERN = /\/Type\s*\/Page(?!s)/g +const COUNT_PATTERN = /\/Count\s+(\d+)/g + +const readChunk = async (file, start, end) => { + const buffer = await file.slice(start, end).arrayBuffer() + + return new TextDecoder('latin1').decode(buffer) +} + +const matchAllAfter = (text, pattern, fromIndex) => { + pattern.lastIndex = 0 + + const matches = [] + let match + + while ((match = pattern.exec(text)) !== null) { + if (match.index >= fromIndex) { + matches.push(match) + } + } + + return matches +} + +const countInText = (text, fromIndex) => { + const pages = matchAllAfter(text, PAGE_OBJECT_PATTERN, fromIndex).length + + let maxCount = 0 + + for (const match of matchAllAfter(text, COUNT_PATTERN, fromIndex)) { + maxCount = Math.max(maxCount, parseInt(match[1], 10)) + } + + return { pages, maxCount } +} + +const isPdfFile = (file) => !file.type || file.type === 'application/pdf' + +const scanPdfText = (text) => { + if (!text.slice(0, CHUNK_SIZE).includes('%PDF-')) { + return null + } + + if (text.includes('/Encrypt')) { + return null + } + + const { pages, maxCount } = countInText(text, 0) + + if (pages > 0) { + return pages + } + + return maxCount > 0 ? maxCount : null +} + +export function countPdfPagesSync (file) { + try { + if (!isPdfFile(file)) { + return null + } + + const url = URL.createObjectURL(file) + + try { + const request = new XMLHttpRequest() + + request.open('GET', url, false) + request.overrideMimeType('text/plain; charset=x-user-defined') + request.send() + + return scanPdfText(request.responseText || '') + } finally { + URL.revokeObjectURL(url) + } + } catch { + return null + } +} + +export async function countPdfPages (file) { + try { + if (!isPdfFile(file)) { + return null + } + + const head = await readChunk(file, 0, Math.min(CHUNK_SIZE, file.size)) + + if (!head.includes('%PDF-')) { + return null + } + + let pages = 0 + let maxCount = 0 + let tail = '' + + for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) { + const text = tail + await readChunk(file, offset, Math.min(offset + CHUNK_SIZE, file.size)) + + if (text.includes('/Encrypt')) { + return null + } + + const counts = countInText(text, tail.length) + + pages += counts.pages + maxCount = Math.max(maxCount, counts.maxCount) + + tail = text.slice(-OVERLAP) + } + + if (pages > 0) { + return pages + } + + return maxCount > 0 ? maxCount : null + } catch { + return null + } +} + +export function reportBlocked ({ pageCount, surface, fileSize }) { + try { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content + + fetch('/page_limit_events', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) + }, + body: JSON.stringify({ page_count: pageCount, surface, file_size: fileSize }) + }).catch(() => {}) + } catch { + // Metrics must never break the upload UX. + } +} diff --git a/app/javascript/template_builder/builder.vue b/app/javascript/template_builder/builder.vue index 2fcfc57042..38f56f51bb 100644 --- a/app/javascript/template_builder/builder.vue +++ b/app/javascript/template_builder/builder.vue @@ -346,6 +346,15 @@ @close="isShowSigningOrderModal = false" /> + + + @@ -365,6 +374,7 @@ import DocumentControls from './controls' import MobileFields from './mobile_fields' import FieldSubmitter from './field_submitter' import SigningOrderModal from './signing_order_modal' +import PageLimitModal from './page_limit_modal' import Toast from './toast' import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments } from '@tabler/icons-vue' import { v4 } from 'uuid' @@ -396,6 +406,7 @@ export default { IconEye, IconDeviceFloppy, SigningOrderModal, + PageLimitModal, Toast }, provide () { @@ -658,7 +669,9 @@ export default { drawOption: null, dragField: null, isDragFile: false, - isShowSigningOrderModal: false + isShowSigningOrderModal: false, + isShowPageLimitModal: false, + pageLimitPageCount: 0 } }, computed: { @@ -795,6 +808,7 @@ export default { window.addEventListener('resize', this.onWindowResize) window.addEventListener('dragleave', this.onWindowDragLeave) + window.addEventListener('docuseal:page-limit-blocked', this.onPageLimitBlocked) this.$nextTick(() => { if (document.location.search?.includes('stripe_connect_success')) { @@ -823,6 +837,7 @@ export default { window.removeEventListener('resize', this.onWindowResize) window.removeEventListener('dragleave', this.onWindowDragLeave) + window.removeEventListener('docuseal:page-limit-blocked', this.onPageLimitBlocked) }, beforeUpdate () { this.documentRefs = [] @@ -1536,6 +1551,10 @@ export default { onUploadFailed (error) { if (error) alert(error) }, + onPageLimitBlocked (event) { + this.pageLimitPageCount = event.detail.pageCount + this.isShowPageLimitModal = true + }, updateFromUpload (data, { showToast = true } = {}) { if (showToast) { this.$refs.toast.show(this.t('document_uploaded_successfully')) diff --git a/app/javascript/template_builder/i18n.js b/app/javascript/template_builder/i18n.js index b2b596d55d..0595485a0f 100644 --- a/app/javascript/template_builder/i18n.js +++ b/app/javascript/template_builder/i18n.js @@ -154,7 +154,10 @@ const en = { and: 'and', or: 'or', start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start a quick tour to learn how to create an send your first document', - start_tour: 'Start Tour' + start_tour: 'Start Tour', + page_limit_title: 'Document exceeds the page limit', + page_limit_body: 'This PDF has {page_count} pages, which exceeds the 120-page limit. Split it into smaller documents under 120 pages and add fields to each document.', + page_limit_close: 'Close' } const es = { diff --git a/app/javascript/template_builder/page_limit_modal.vue b/app/javascript/template_builder/page_limit_modal.vue new file mode 100644 index 0000000000..0e20879d9a --- /dev/null +++ b/app/javascript/template_builder/page_limit_modal.vue @@ -0,0 +1,51 @@ + + + diff --git a/app/javascript/template_builder/upload.vue b/app/javascript/template_builder/upload.vue index a7882f992c..406edde475 100644 --- a/app/javascript/template_builder/upload.vue +++ b/app/javascript/template_builder/upload.vue @@ -41,6 +41,7 @@