diff --git a/backend/app/controllers/counts_exports_controller.rb b/backend/app/controllers/counts_exports_controller.rb index ca459ad..537d512 100644 --- a/backend/app/controllers/counts_exports_controller.rb +++ b/backend/app/controllers/counts_exports_controller.rb @@ -9,6 +9,14 @@ class CountsExportsController < ApplicationController token = CountsExportDownload.prepare(exporter, session[:counts_export_owner]) response.headers['Cache-Control'] = 'private, no-store' render json: { token: } + rescue CountsExportDownload::Busy + head :too_many_requests + rescue CountsExportDownload::TooLarge + head 413 + rescue CountsExportDownload::TimedOut + head :gateway_timeout + rescue CountsExportDownload::StorageFull, Errno::ENOSPC + head :insufficient_storage rescue ArgumentError, TypeError, KeyError, JSON::ParserError head :bad_request end diff --git a/backend/app/services/counts_export_download.rb b/backend/app/services/counts_export_download.rb index 0dbbd74..b773c5c 100644 --- a/backend/app/services/counts_export_download.rb +++ b/backend/app/services/counts_export_download.rb @@ -4,23 +4,70 @@ require 'fileutils' # Finish generation before reporting readiness; downloads never hold the full file in RAM. class CountsExportDownload EXPIRY = 10.minutes + MAX_CONCURRENT_EXPORTS = 2 + MAX_FILE_BYTES = 128.megabytes + MAX_STORED_BYTES = 512.megabytes + MAX_EXPORT_SECONDS = 120 + class Busy < StandardError; end + class TooLarge < StandardError; end + class TimedOut < StandardError; end + class StorageFull < StandardError; end def self.directory = Rails.root.join('tmp', 'counts_exports') def self.verifier = Rails.application.message_verifier('counts_export_download') def self.prepare(exporter, owner) + FileUtils.mkdir_p(directory, mode: 0o700) + slot = acquire_slot + + begin + generate(exporter, owner) + ensure + slot.close + end + end + + def self.acquire_slot + MAX_CONCURRENT_EXPORTS.times do |index| + path = directory.join(".slot-#{ index }.lock") + handle = File.open(path, File::RDWR | File::CREAT, 0o600) + return handle if handle.flock(File::LOCK_EX | File::LOCK_NB) + + handle.close + end + raise Busy + end + + def self.generate(exporter, owner) FileUtils.mkdir_p(directory, mode: 0o700) Dir.glob(directory.join('counts-*.export')).each do |path| File.unlink(path) if File.mtime(path) < Time.current - EXPIRY rescue Errno::ENOENT # Another request may have removed an expired download. end + stored = Dir.glob(directory.join('counts-*')).sum do |path| + File.size(path) + rescue Errno::ENOENT + 0 + end + reserved = MAX_CONCURRENT_EXPORTS * MAX_FILE_BYTES + raise StorageFull if stored + reserved > MAX_STORED_BYTES + file = Tempfile.create(['counts-', '.part'], directory) complete = false + bytes = 0 + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + MAX_EXPORT_SECONDS path = file.path begin file.binmode - exporter.body.each { file.write(_1) } + exporter.body.each do |chunk| + raise TimedOut if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + + bytes += chunk.bytesize + raise TooLarge if bytes > MAX_FILE_BYTES + + file.write(chunk) + end file.close finished_path = path.sub(/\.part\z/, '.export') File.rename(path, finished_path) diff --git a/frontend/src/components/common/SortableItem.tsx b/frontend/src/components/common/SortableItem.tsx index ada6307..a57c346 100644 --- a/frontend/src/components/common/SortableItem.tsx +++ b/frontend/src/components/common/SortableItem.tsx @@ -9,9 +9,9 @@ import type { ReactNode } from 'react' type Name = { text: string; language: string } -const SortableItem = ({ id, name, fixed, children, moveGroup }: { +const SortableItem = ({ id, name, fixed, children, moveGroup, handleId }: { id: string; name: Name; fixed?: boolean; children: ReactNode - moveGroup: (direction: number) => void }, + moveGroup: (direction: number) => void; handleId: string }, ) => { const motion = useClientMotion () const item = useSortable ({ id, disabled: fixed, @@ -25,15 +25,16 @@ const SortableItem = ({ id, name, fixed, children, moveGroup }: { item.isDragging && 'opacity-30 ring-2 ring-blue-500 dark:ring-blue-400')}>