aaaaaaaaa

This commit is contained in:
End Nightshade 2026-02-10 09:53:18 -07:00
parent ae1ffadfcd
commit def7b0511f
No known key found for this signature in database
7 changed files with 293 additions and 172 deletions

View file

@ -37,7 +37,7 @@ class Components::Uploads::Index < Components::Base
label(for: "dropzone-file-input", class: "btn btn-primary", style: "cursor: pointer;") do
render Primer::Beta::Octicon.new(icon: :upload, mr: 1)
plain "Upload File"
plain "Upload Files"
end
end
end

View file

@ -1,116 +1,82 @@
# frozen_string_literal: true
module API
module V4
class UploadsController < ApplicationController
before_action :check_quota, only: [ :create, :create_from_url ]
class UploadsController < ApplicationController
before_action :set_upload, only: [ :destroy ]
# POST /api/v4/upload
def create
file = params[:file]
def index
@uploads = current_user.uploads.includes(:blob).recent
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"
# Pre-gen upload ID for predictable storage path
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/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)
# Check quota after download (URL upload size unknown beforehand)
quota_service = QuotaService.new(current_user)
unless quota_service.can_upload?(0) # Already uploaded, check if now over quota
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
private
def check_quota
# For direct uploads, check file size before processing
if params[:file].present?
file_size = params[:file].size
quota_service = QuotaService.new(current_user)
policy = quota_service.current_policy
# Check per-file size limit
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
# Check if upload would exceed total storage quota
unless quota_service.can_upload?(file_size)
usage = quota_service.current_usage
render json: quota_error_json(usage), status: :payment_required
nil
end
end
# For URL uploads, quota is checked after download in create_from_url
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
if params[:query].present?
@uploads = @uploads.search_by_filename(params[:query])
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

View file

@ -2,7 +2,6 @@
class UploadsController < ApplicationController
before_action :set_upload, only: [ :destroy ]
before_action :check_quota, only: [ :create ]
def index
@uploads = current_user.uploads.includes(:blob).recent
@ -15,32 +14,63 @@ class UploadsController < ApplicationController
end
def create
uploaded_file = params[:file]
uploaded_files = extract_uploaded_files
if uploaded_file.blank?
redirect_to uploads_path, alert: "Please select a file to upload."
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.uplaods.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=[]
# 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"
# pre-gen upload ID for predictable storage path
upload_id = SecureRandom.uuid_v7
sanitized_filename = ActiveStorage::Filename.new(uploaded_file.original_filename).sanitized
storage_key = "#{upload_id}/#{sanitized_filename}"
blob = ActiveStorage::Blob.create_and_upload!(
io: uploaded_file.tempfile,
filename: uploaded_file.original_filename,
content_type: content_type,
key: storage_key
)
@upload = current_user.uploads.create!(
id: upload_id,
blob: blob,
provenance: :web
)
redirect_to uploads_path, notice: "File uploaded successfully!"
rescue StandardError => e
@ -48,40 +78,31 @@ class UploadsController < ApplicationController
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."
files.reject(&:blank?)
end
private
def check_quota
uploaded_file = params[:file]
return if uploaded_file.blank? # Let create action handle missing file
quota_service = QuotaService.new(current_user)
file_size = uploaded_file.size
policy = quota_service.current_policy
# Check per-file size limit
if file_size > policy.max_file_size
redirect_to uploads_path, alert: "File size (#{ActiveSupport::NumberHelper.number_to_human_size(file_size)}) exceeds your limit of #{ActiveSupport::NumberHelper.number_to_human_size(policy.max_file_size)} per file."
return
def build_flash_message(result)
messages = []
if result.uploads.any?
messages << "#{result.uploads.size} file(s) uploaded successfully"
end
# Check if upload would exceed total storage quota
unless quota_service.can_upload?(file_size)
usage = quota_service.current_usage
redirect_to uploads_path, alert: "Uploading this file would exceed your storage quota. You're using #{ActiveSupport::NumberHelper.number_to_human_size(usage[:storage_used])} of #{ActiveSupport::NumberHelper.number_to_human_size(usage[:storage_limit])}."
nil
if result.failed.any?
messages << "#{result.failed.size} file(s) failed to upload"
end
messages.join(", ")
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])
@upload = current_user.uploads.find(params[:id])
end
end

View file

@ -1,4 +1,4 @@
(function() {
(function () {
let dropzone;
let counter = 0;
let fileInput, form;
@ -22,10 +22,10 @@
initialized = true;
// Handle file input change
// Handle file input change (supports multiple files)
fileInput.addEventListener("change", (e) => {
const file = e.target.files[0];
if (file) {
const files = e.target.files;
if (files && files.length > 0) {
form.requestSubmit();
}
});
@ -56,7 +56,7 @@
}
});
// Handle file drop
// Handle file drop (supports multiple files)
document.addEventListener("drop", (e) => {
if (!fileInput) return;
e.preventDefault();
@ -77,9 +77,15 @@
dropzone.classList.add("file-dropzone");
const title = document.createElement("h1");
title.innerText = "Drop your file here";
title.innerText = "Drop your files here";
dropzone.appendChild(title);
const subtitle = document.createElement("p");
subtitle.innerText = "Up to 40 files at once";
subtitle.style.marginTop = "8px";
subtitle.style.opacity = "0.7";
dropzone.appendChild(subtitle);
document.body.appendChild(dropzone);
document.body.style.overflow = "hidden";

View file

@ -5,14 +5,14 @@ require "open-uri"
class Upload < ApplicationRecord
include PgSearch::Model
# UUID v7 primary key (automatic via migration)
# UUID v7 primary key
belongs_to :user
belongs_to :blob, class_name: "ActiveStorage::Blob"
after_destroy :purge_blob
# Delegate file metadata to blob (no duplication!)
# Delegate file metadata to blob
delegate :filename, :byte_size, :content_type, :checksum, to: :blob
# Search configuration
@ -73,16 +73,13 @@ class Upload < ApplicationRecord
# Create upload from URL (for API/rescue operations)
def self.create_from_url(url, user:, provenance:, original_url: nil, authorization: nil, filename: nil)
conn = 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
conn.options.open_timeout = 30
conn.options.timeout = 120
con = build_http_client
headers = {}
headers["Authorization"] = authorization if authorization.present?
pre_check_quota_via_head(conn, url, headers, user)
response = conn.get(url, nil, headers)
if response.status.between?(300, 399)
location = response.headers["location"]
@ -90,7 +87,7 @@ class Upload < ApplicationRecord
end
raise "Failed to download: #{response.status}" unless response.success?
filename ||= File.basename(URI.parse(url).path)
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"
@ -116,6 +113,44 @@ class Upload < ApplicationRecord
)
end
class << self
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)}"
end
endreturn 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
def purge_blob

View file

@ -0,0 +1,91 @@
# frozen_string_literal: true
class BatchUploadService
MAX_FILES_PER_BATCH = 40
Result = Data.define(:uploads, :failed)
FailedUpload = Data.define(:filename, :reason)
def initialize(user:, provenance:)
@user = user
@provenance = provenance
@quota_service = QuotaService.new(user)
@policy = @quota_service.current_policy
end
# Process batch
def process_files(files)
uploads = []
failed = []
# Track storage used in batch
batch_bytes_used = 0
current_storage = @user.total_storage_bytes
max_storage = @policy.max_total_storage
files.each do |file|
filename = file.original_filename
file_size = file.size
# Check per-file size
if file_size > @policy.max_file_size
failed << FailedUpload[
filename,
"File size (#{human_size(file_size)}) exceeds limit of #{human_size(@policy.max_file_size)}"
]
next
end
# Check if file exceed total quota
projected_total = current_storage + batch_bytes_used + file_size
if projected_total > max_storage
remaining = max_storage - current_storage - batch_bytes_used
failed << FailedUpload[
filename,
"Would exceed storage quota (#{human_size(remaining)} remaining)"
]
next
end
# Upload file
begin
upload = create_upload(file)
uploads << upload
batch_bytes_used += file_size
rescue StandardError => e
failed << FailedUpload[filename, "Upload error: #{e.message}"]
end
end
Result[uploads, failed]
end
private
def create_upload(file)
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
)
@user.uploads.create!(
id: upload_id,
blob: blob,
provenance: @provenance
)
end
def human_size(bytes)
ActiveSupport::NumberHelper.number_to_human_size(bytes)
end
end

View file

@ -21,7 +21,9 @@ 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 "upload_from_url", to: "uploads#create_from_url"
patch "uploads/:id/rename", to: "uploads#rename", as: :upload_rename
post "revoke", to: "api_keys#revoke"
end
end