mirror of
https://github.com/System-End/cdn.git
synced 2026-04-19 19:45:07 +00:00
aaaaaaaaa
This commit is contained in:
parent
ae1ffadfcd
commit
def7b0511f
7 changed files with 293 additions and 172 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
91
app/services/batch_upload_service.rb
Normal file
91
app/services/batch_upload_service.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue