diff --git a/app/components/uploads/index.rb b/app/components/uploads/index.rb index b80ec5c..19d0097 100644 --- a/app/components/uploads/index.rb +++ b/app/components/uploads/index.rb @@ -87,7 +87,7 @@ class Components::Uploads::Index < Components::Base if query.present? "Try a different search query" else - "Drag and drop files anywhere on this page, or use the Upload button" + "Drag and drop files anywhere on this page, or use the Upload button (up to 40 files at once)" end end end @@ -101,7 +101,7 @@ class Components::Uploads::Index < Components::Base def dropzone_form form_with url: uploads_path, method: :post, multipart: true, data: { dropzone_form: true } do - input(type: "file", name: "file", id: "dropzone-file-input", data: { dropzone_input: true }, style: "display: none;") + input(type: "file", name: "files[]", id: "dropzone-file-input", multiple: true, data: { dropzone_input: true }, style: "display: none;") end end end diff --git a/app/controllers/api/v4/uploads_controller.rb b/app/controllers/api/v4/uploads_controller.rb index 06c690e..0cceff0 100644 --- a/app/controllers/api/v4/uploads_controller.rb +++ b/app/controllers/api/v4/uploads_controller.rb @@ -1,82 +1,157 @@ # frozen_string_literal: true -class UploadsController < ApplicationController - before_action :set_upload, only: [ :destroy ] +module API + module V4 + class UploadsController < ApplicationController + before_action :check_quota, only: [ :create, :create_from_url ] - def index - @uploads = current_user.uploads.includes(:blob).recent + # POST /api/v4/upload + def create + file = params[:file] - if params[:query].present? - @uploads = @uploads.search_by_filename(params[:query]) + unless file.present? + render json: { error: "Missing file parameter" }, status: :bad_request + return + end + + content_type = Marcel::MimeType.for(file.tempfile, name: file.original_filename) || file.content_type || "application/octet-stream" + + upload_id = SecureRandom.uuid_v7 + sanitized_filename = ActiveStorage::Filename.new(file.original_filename).sanitized + storage_key = "#{upload_id}/#{sanitized_filename}" + + blob = ActiveStorage::Blob.create_and_upload!( + io: file.tempfile, + filename: file.original_filename, + content_type: content_type, + key: storage_key + ) + + upload = current_user.uploads.create!(id: upload_id, blob: blob, provenance: :api) + + render json: upload_json(upload), status: :created + rescue => e + render json: { error: "Upload failed: #{e.message}" }, status: :unprocessable_entity + end + + # POST /api/v4/uploads (batch) + def create_batch + files = params[:files] + + unless files.present? && files.is_a?(Array) + render json: { error: "Missing files[] parameter" }, status: :bad_request + return + end + + if files.size > BatchUploadService::MAX_FILES_PER_BATCH + render json: { + error: "Too many files", + detail: "Maximum #{BatchUploadService::MAX_FILES_PER_BATCH} files per batch, got #{files.size}" + }, status: :bad_request + return + end + + service = BatchUploadService.new(user: current_user, provenance: :api) + result = service.process_files(files) + + response = { + uploads: result.uploads.map { |u| upload_json(u) }, + failed: result.failed.map { |f| { filename: f.filename, reason: f.reason } } + } + + status = result.uploads.any? ? :created : :unprocessable_entity + render json: response, status: status + end + + # POST /api/v4/upload_from_url + def create_from_url + url = params[:url] + + unless url.present? + render json: { error: "Missing url parameter" }, status: :bad_request + return + end + + download_auth = request.headers["X-Download-Authorization"] + upload = Upload.create_from_url(url, user: current_user, provenance: :api, original_url: url, authorization: download_auth) + + quota_service = QuotaService.new(current_user) + unless quota_service.can_upload?(0) + if current_user.total_storage_bytes > quota_service.current_policy.max_total_storage + upload.destroy! + usage = quota_service.current_usage + render json: quota_error_json(usage), status: :payment_required + return + end + end + + render json: upload_json(upload), status: :created + rescue => e + render json: { error: "Upload failed: #{e.message}" }, status: :unprocessable_entity + end + + # PATCH /api/v4/uploads/:id/rename + def rename + upload = current_user.uploads.find(params[:id]) + new_filename = params[:filename].to_s.strip + + if new_filename.blank? + render json: { error: "Missing filename parameter" }, status: :bad_request + return + end + + upload.rename!(new_filename) + render json: upload_json(upload) + rescue ActiveRecord::RecordNotFound + render json: { error: "Upload not found" }, status: :not_found + rescue => e + render json: { error: "Rename failed: #{e.message}" }, status: :unprocessable_entity + end + + private + + def check_quota + if params[:file].present? + file_size = params[:file].size + quota_service = QuotaService.new(current_user) + policy = quota_service.current_policy + + if file_size > policy.max_file_size + usage = quota_service.current_usage + render json: quota_error_json(usage, "File size exceeds your limit of #{ActiveSupport::NumberHelper.number_to_human_size(policy.max_file_size)} per file"), status: :payment_required + return + end + + unless quota_service.can_upload?(file_size) + usage = quota_service.current_usage + render json: quota_error_json(usage), status: :payment_required + nil + end + end + end + + def quota_error_json(usage, custom_message = nil) + { + error: custom_message || "Storage quota exceeded", + quota: { + storage_used: usage[:storage_used], + storage_limit: usage[:storage_limit], + quota_tier: usage[:policy], + percentage_used: usage[:percentage_used] + } + } + end + + def upload_json(upload) + { + id: upload.id, + filename: upload.filename.to_s, + size: upload.byte_size, + content_type: upload.content_type, + url: upload.cdn_url, + created_at: upload.created_at.iso8601 + } + end end - - @uploads = @uploads.page(params[:page]).per(50) - end - - def create - uploaded_files = extract_uploaded_files - - if uploaded_files.empty? - redirect_to uploads_path, alert: "Please select at least one file to upload." - return - end - - if uploaded_files.size > BatchUploadService::MAX_FILES_PER_BATCH - redirect_to uploads_path, alert: "Too many files selected. Max #{BatchUploadService::MAX_FILES_PER_BATCH} files allowed per upload." - return - end - - service = BatchUploadService.new(user: current_user, provenance: :web) - result = service.process_files(uploaded_files) - - flash_message = build_flash_message(result) - - if result.uploads.any? - redirect_to uploads_path, notice: flash_message - else - redirect_to uploads_path, alert: flash_message - end - rescue StandardError => e - event = Sentry.capture_exception(e) - redirect_to uploads_path, alert: "Upload failed: #{e.message} (Error ID: #{event&.event_id})" - end - - def destroy - authorize @upload - - @upload.destroy! - redirect_back fallback_location: uploads_path, notice: "Upload deleted successfully." - rescue Pundit::NotAuthorizedError - redirect_back fallback_location: uploads_path, alert: "You are not authorized to delete this upload." - end - - private - - def extract_uploaded_files - files = [] - files.concat(Array(params[:files])) if params[:files].present? - files << params[:file] if params[:file].present? - files.reject(&:blank?) - end - - def build_flash_message(result) - parts = [] - - if result.uploads.any? - count = result.uploads.size - filenames = result.uploads.map { |u| u.filename.to_s }.join(", ") - parts << "Uploaded #{count} #{'file'.pluralize(count)}: #{filenames}" - end - - if result.failed.any? - failures = result.failed.map { |f| "#{f.filename} (#{f.reason})" }.join("; ") - parts << "Failed: #{failures}" - end - - parts.join(". ") - end - - def set_upload - @upload = Upload.find(params[:id]) end end diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index fb27392..25da1b3 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class UploadsController < ApplicationController - before_action :set_upload, only: [ :destroy ] + before_action :set_upload, only: [ :update, :destroy ] def index @uploads = current_user.uploads.includes(:blob).recent @@ -31,78 +31,67 @@ class UploadsController < ApplicationController flash_message = build_flash_message(result) - if result.uplaods.any? + if result.uploads.any? redirect_to uploads_path, notice: flash_message else - redirect_to uploads_path, alert: flash_message + redirect_to uploads_path, alert: flash_message end - rescue StandardError => e - event = Sentry.capture_exception(e) - redirect_to uploads_path, alert: "Upload failed: #{e.message} (Error ID: #{event&.event_id})" - end - - def destroy - authorize @upload - - @upload.destroy! - redirect_back fallback_location: uploads_path, notice: "Upload deleted successfully." - rescue Pundit::NotAuthorizedError - redirect_back fallback_location: uploads_path, alert: "You are not authorized to delete this upload." - end - - private - - def extract_uploaded_files - files=[] - - # mult files via files[] param - if params[:files].present? - files.concat(Array(params[:files])) - end - - if params[:file].present? - files << params[:file] - end - - files.reject(&:blank?) - end - - - content_type = Marcel::MimeType.for(uploaded_file.tempfile, name: uploaded_file.original_filename) || uploaded_file.content_type || "application/octet-stream" - - - - redirect_to uploads_path, notice: "File uploaded successfully!" rescue StandardError => e event = Sentry.capture_exception(e) redirect_to uploads_path, alert: "Upload failed: #{e.message} (Error ID: #{event&.event_id})" end - files.reject(&:blank?) + def update + authorize @upload + + new_filename = params[:filename].to_s.strip + if new_filename.blank? + redirect_to uploads_path, alert: "Filename can't be blank." + return + end + + @upload.rename!(new_filename) + redirect_to uploads_path, notice: "Renamed to #{@upload.filename}" + rescue Pundit::NotAuthorizedError + redirect_to uploads_path, alert: "You are not authorized to rename this upload." + end + + def destroy + authorize @upload + + @upload.destroy! + redirect_back fallback_location: uploads_path, notice: "Upload deleted successfully." + rescue Pundit::NotAuthorizedError + redirect_back fallback_location: uploads_path, alert: "You are not authorized to delete this upload." + end + + private + + def extract_uploaded_files + files = [] + files.concat(Array(params[:files])) if params[:files].present? + files << params[:file] if params[:file].present? + files.reject(&:blank?) end def build_flash_message(result) - messages = [] + parts = [] + if result.uploads.any? - messages << "#{result.uploads.size} file(s) uploaded successfully" + count = result.uploads.size + filenames = result.uploads.map { |u| u.filename.to_s }.join(", ") + parts << "Uploaded #{count} #{'file'.pluralize(count)}: #{filenames}" end if result.failed.any? - messages << "#{result.failed.size} file(s) failed to upload" + failures = result.failed.map { |f| "#{f.filename} (#{f.reason})" }.join("; ") + parts << "Failed: #{failures}" end - messages.join(", ") - end - - if result.failed.any? - failures = result.failed.map { |f| "#{f.filename} (#{f.reason})" }.join(", ") - parts << "Failed: #{failures}" - end - - parts.join(". ") + parts.join(". ") end def set_upload - @upload = current_user.uploads.find(params[:id]) + @upload = Upload.find(params[:id]) end end diff --git a/app/models/upload.rb b/app/models/upload.rb index e14fb70..421ae7b 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -5,17 +5,13 @@ require "open-uri" class Upload < ApplicationRecord include PgSearch::Model - # UUID v7 primary key - belongs_to :user belongs_to :blob, class_name: "ActiveStorage::Blob" after_destroy :purge_blob - # Delegate file metadata to blob delegate :filename, :byte_size, :content_type, :checksum, to: :blob - # Search configuration pg_search_scope :search_by_filename, associated_against: { blob: :filename @@ -32,11 +28,9 @@ class Upload < ApplicationRecord }, using: { tsearch: { prefix: true } } - # Aliases for consistency alias_method :file_size, :byte_size alias_method :mime_type, :content_type - # Provenance enum enum :provenance, { slack: "slack", web: "web", @@ -56,13 +50,11 @@ class Upload < ApplicationRecord ActiveSupport::NumberHelper.number_to_human_size(byte_size) end - # Direct URL to public R2 bucket def assets_url host = ENV.fetch("CDN_ASSETS_HOST", "cdn.hackclub-assets.com") "https://#{host}/#{blob.key}" end - # Get CDN URL (uses external uploads controller) def cdn_url Rails.application.routes.url_helpers.external_upload_url( id:, @@ -71,9 +63,20 @@ class Upload < ApplicationRecord ) end - # Create upload from URL (for API/rescue operations) + # Rename the display filename (storage key stays the same) + def rename!(new_filename) + sanitized = ActiveStorage::Filename.new(new_filename).sanitized + + # Preserve the original extension if the user didn't provide one + original_ext = File.extname(blob.filename.to_s) + new_ext = File.extname(sanitized) + sanitized = "#{sanitized}#{original_ext}" if new_ext.blank? && original_ext.present? + + blob.update!(filename: sanitized) + end + def self.create_from_url(url, user:, provenance:, original_url: nil, authorization: nil, filename: nil) - con = build_http_client + conn = build_http_client headers = {} headers["Authorization"] = authorization if authorization.present? @@ -89,9 +92,10 @@ class Upload < ApplicationRecord filename ||= extract_filename_from_url(url) body = response.body - content_type = Marcel::MimeType.for(StringIO.new(body), name: filename) || response.headers["content-type"] || "application/octet-stream" + content_type = Marcel::MimeType.for(StringIO.new(body), name: filename) || + response.headers["content-type"] || + "application/octet-stream" - # Pre-generate upload ID for predictable storage path upload_id = SecureRandom.uuid_v7 sanitized_filename = ActiveStorage::Filename.new(filename).sanitized storage_key = "#{upload_id}/#{sanitized_filename}" @@ -114,41 +118,43 @@ class Upload < ApplicationRecord end class << self - private + private - def build_http_client - Faraday.new(ssl: { verify: true, verify_mod: OpenSSL::SSL::VERIFY_PEER }) do |f| - f.response Faraday :follow_redirects, limit: 5 - f.adapter Faraday.default_adapter - end.tap do |conn| - conn.options.open_timeout = 30 - conn.options.timeout = 120 - end - end - - def pre_check_quota_via_head(conn, url, headers, user) - head_response = conn.head(url, nil, headers) - return unless head_response.success? - - content_length = head_response.headers["content-length"]&.to_i - return unless content_length && content_length > 0 - - quota_service = QuotaService.new(user) - policy = quota_service.current_policy - - if content_length > policy.max_file_size - raise "File too large: #{ActiveSupport::NumberHelper.number_to_human_size(content_length)} exceeds limit of #{ActiveSupport::NumberHelper.number_to_human_size(policy.max_file_size)}" + def build_http_client + Faraday.new(ssl: { verify: true, verify_mode: OpenSSL::SSL::VERIFY_PEER }) do |f| + f.response :follow_redirects, limit: 5 + f.adapter Faraday.default_adapter + end.tap do |conn| + conn.options.open_timeout = 30 + conn.options.timeout = 120 + end end - endreturn if quota_service.can_upload?(content_length) - raise "File would exceed storage quota" - rescue Faraday::Error - nil - end + def pre_check_quota_via_head(conn, url, headers, user) + head_response = conn.head(url, nil, headers) + return unless head_response.success? - def extract_filename_from_url(url) - File.basename(URI.parse(url).path).presence || "download" - end + content_length = head_response.headers["content-length"]&.to_i + return unless content_length && content_length > 0 + + quota_service = QuotaService.new(user) + policy = quota_service.current_policy + + if content_length > policy.max_file_size + raise "File too large: #{ActiveSupport::NumberHelper.number_to_human_size(content_length)} " \ + "exceeds limit of #{ActiveSupport::NumberHelper.number_to_human_size(policy.max_file_size)}" + end + + return if quota_service.can_upload?(content_length) + + raise "File would exceed storage quota" + rescue Faraday::Error + nil + end + + def extract_filename_from_url(url) + File.basename(URI.parse(url).path).presence || "download" + end end private diff --git a/app/policies/upload_policy.rb b/app/policies/upload_policy.rb index 2117b74..d0b08ca 100644 --- a/app/policies/upload_policy.rb +++ b/app/policies/upload_policy.rb @@ -6,6 +6,10 @@ class UploadPolicy < ApplicationPolicy user.is_admin? || record.user_id == user.id end + def update? + user.is_admin? || record.user_id == user.id + end + class Scope < ApplicationPolicy::Scope def resolve if user.is_admin? diff --git a/config/routes.rb b/config/routes.rb index 8f29a76..1c36950 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,7 +13,7 @@ Rails.application.routes.draw do get "/auth/hack_club/callback", to: "sessions#create" get "/auth/failure", to: "sessions#failure" - resources :uploads, only: [ :index, :create, :destroy ] + resources :uploads, only: [ :index, :create, :update, :destroy ] resources :api_keys, only: [ :index, :create, :destroy ] @@ -21,7 +21,7 @@ Rails.application.routes.draw do namespace :v4 do get "me", to: "users#show" post "upload", to: "uploads#create" - psot "uploads", to: "uploads#create_batch" + post "uploads", to: "uploads#create_batch" post "upload_from_url", to: "uploads#create_from_url" patch "uploads/:id/rename", to: "uploads#rename", as: :upload_rename post "revoke", to: "api_keys#revoke"