Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions app/controllers/page_limit_events_controller.rb
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[non-blocking] This endpoint has no rate limiting. Since verify_authenticity_token is skipped for create, any signed in user can call it repeatedly to flood the logs with fabricated events. Worth a throttle given its only job is metrics.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good callout. There's currently no throttle infra in the app (no Rack::Attack; cache is per-process memory_store, null_store in test), so a hand-rolled throttle here would be weak and untestable. The endpoint requires auth and stamps every hit with account/user id, so abuse is attributable. Happy to add Rack::Attack as a follow-up if you want it.

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
173 changes: 130 additions & 43 deletions app/javascript/elements/dashboard_dropzone.js
Original file line number Diff line number Diff line change
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>`

// 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',
Expand Down Expand Up @@ -75,66 +141,69 @@ 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) => {
e.preventDefault()

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')
})
}
}

Expand All @@ -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
Expand Down
36 changes: 35 additions & 1 deletion app/javascript/elements/file_dropzone.js
Original file line number Diff line number Diff line change
@@ -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] = [
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading