From d7f38d72a8970c063d971a502e34cab1398961b6 Mon Sep 17 00:00:00 2001 From: Ian Rohde Date: Wed, 18 Mar 2026 11:34:49 -0700 Subject: [PATCH 1/4] Add delete and all_keys_under methods to S3 service Adds delete_object support for removing S3 objects and a helper to list all keys under a prefix (for directory deletion). Stubs both in S3Stubbed for dev/testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/charge/services/s3.rb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/charge/services/s3.rb b/lib/charge/services/s3.rb index 2528724..e959864 100644 --- a/lib/charge/services/s3.rb +++ b/lib/charge/services/s3.rb @@ -67,6 +67,18 @@ def upload file, bucket, key puts resp.to_h end + def delete bucket, key + puts "Deleting #{key} from bucket #{bucket}..." + client.delete_object({ + bucket: bucket, + key: key, + }) + end + + def all_keys_under bucket, prefix + find_whole_bucket(bucket, prefix).map { |item| item['key'] } + end + def exists_in_s3? bucket, key s3 = Aws::S3::Resource.new(region: @region) s3bucket = s3.bucket(bucket) @@ -128,6 +140,9 @@ class S3Stubbed < S3 def upload file, bucket, key puts "UPLOADS STUBBED FOR TESTING!" end + def delete bucket, key + puts "DELETES STUBBED FOR TESTING!" + end end end end From a510248abbc8b281b12706bb26fcb7d7cb1c14d0 Mon Sep 17 00:00:00 2001 From: Ian Rohde Date: Wed, 18 Mar 2026 11:35:03 -0700 Subject: [PATCH 2/4] Add file and directory deletion support Adds DeleteSpec value object, factory, Deleter action, and a two-step confirmation view. Supports single file and directory deletion from both source and live buckets with cache busting. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/charge/actions/deleter.rb | 64 +++++++++++++++++++++ lib/charge/factories/delete_spec_factory.rb | 13 +++++ lib/charge/values/delete_spec.rb | 13 +++++ views/delete_confirm.erb | 23 ++++++++ 4 files changed, 113 insertions(+) create mode 100644 lib/charge/actions/deleter.rb create mode 100644 lib/charge/factories/delete_spec_factory.rb create mode 100644 lib/charge/values/delete_spec.rb create mode 100644 views/delete_confirm.erb diff --git a/lib/charge/actions/deleter.rb b/lib/charge/actions/deleter.rb new file mode 100644 index 0000000..ee9ee7a --- /dev/null +++ b/lib/charge/actions/deleter.rb @@ -0,0 +1,64 @@ +require 'charge' +require 'helpers/streaming_output' +require 'services/cache_buster' + +module Charge + module Actions + class Deleter + include Helpers::StreamingOutput + def initialize delete_spec + @delete_spec = delete_spec + @s3 = Config::s3service.new + end + + def delete + if @delete_spec.is_directory + delete_directory + else + delete_single_file + end + provide_browse_link + end + + private + def delete_single_file + key = @delete_spec.key + stream_msg "Deleting file '#{key}'..." + @s3.delete(Config.source_bucket, key) + stream_msg "Deleted from source bucket." + @s3.delete(Config.live_bucket, key) + stream_msg "Deleted from live bucket." + Services::CacheBuster.bust_cache_on_hosts key + stream_msg "Cache busted for '#{key}'." + end + + def delete_directory + prefix = @delete_spec.key + stream_msg "Deleting all files under '#{prefix}'..." + keys = @s3.all_keys_under(Config.live_bucket, prefix) + count = 0 + keys.each do |key| + count += 1 + stream_msg "Deleting file #{count}: #{key}..." + @s3.delete(Config.source_bucket, key) + @s3.delete(Config.live_bucket, key) + Services::CacheBuster.bust_cache_on_hosts key + end + stream_msg "Deleted #{count} file(s)." + end + + def provide_browse_link + key = @delete_spec.key + parent = parent_directory(key) + stream_msg %Q(Back to #{parent}) + end + + def parent_directory key + parts = key.chomp('/').split('/') + parts.pop + return Config.base_prefix if parts.empty? + parts.join('/') + '/' + end + end + end +end diff --git a/lib/charge/factories/delete_spec_factory.rb b/lib/charge/factories/delete_spec_factory.rb new file mode 100644 index 0000000..7ff6b36 --- /dev/null +++ b/lib/charge/factories/delete_spec_factory.rb @@ -0,0 +1,13 @@ +require 'values/delete_spec' + +module Charge + module Factories + class DeleteSpecFactory + class << self + def from_params key, is_directory: + Values::DeleteSpec.new(key, is_directory: is_directory) + end + end + end + end +end diff --git a/lib/charge/values/delete_spec.rb b/lib/charge/values/delete_spec.rb new file mode 100644 index 0000000..d06d4e3 --- /dev/null +++ b/lib/charge/values/delete_spec.rb @@ -0,0 +1,13 @@ +module Charge + module Values + class DeleteSpec + attr :key + attr :is_directory + + def initialize key, is_directory: false + @key = key + @is_directory = is_directory + end + end + end +end diff --git a/views/delete_confirm.erb b/views/delete_confirm.erb new file mode 100644 index 0000000..7f9c1d0 --- /dev/null +++ b/views/delete_confirm.erb @@ -0,0 +1,23 @@ +

Confirm Deletion

+ +<% if @is_directory %> +

Delete directory: <%= @key %>

+

The following files will be permanently deleted:

+
    + <% @affected_keys.each do |key| %> +
  • <%= key %>
  • + <% end %> +
+<% else %> +

Delete file: <%= @key %>

+

This file will be permanently deleted from both source and live buckets.

+<% end %> + +
+ + +
+ +
+Cancel and go back From 964ffbe834ec86e6317072450de32f382b60b4de Mon Sep 17 00:00:00 2001 From: Ian Rohde Date: Wed, 18 Mar 2026 11:35:15 -0700 Subject: [PATCH 3/4] Add bulk upload action and factory method BulkUploader iterates over multiple UploadSpecs, delegating each to the existing Uploader with a shared output stream for unified progress. Adds from_form_params_bulk to UploadSpecFactory. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/charge/actions/bulk_uploader.rb | 28 +++++++++++++++++++++ lib/charge/factories/upload_spec_factory.rb | 22 ++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 lib/charge/actions/bulk_uploader.rb diff --git a/lib/charge/actions/bulk_uploader.rb b/lib/charge/actions/bulk_uploader.rb new file mode 100644 index 0000000..5d89b7f --- /dev/null +++ b/lib/charge/actions/bulk_uploader.rb @@ -0,0 +1,28 @@ +require 'charge' +require 'actions/uploader' +require 'helpers/streaming_output' + +module Charge + module Actions + class BulkUploader + include Helpers::StreamingOutput + def initialize upload_specs + @upload_specs = upload_specs + end + + def upload + total = @upload_specs.length + stream_msg "Starting bulk upload of #{total} file(s)..." + @upload_specs.each_with_index do |spec, index| + stream_msg "
" + stream_msg "File #{index + 1} of #{total}: #{spec.filename}" + uploader = Actions::Uploader.new spec + uploader.set_output_stream @output_stream + uploader.upload + end + stream_msg "
" + stream_msg "Bulk upload complete! #{total} file(s) processed." + end + end + end +end diff --git a/lib/charge/factories/upload_spec_factory.rb b/lib/charge/factories/upload_spec_factory.rb index 0b2c0ba..262ec93 100644 --- a/lib/charge/factories/upload_spec_factory.rb +++ b/lib/charge/factories/upload_spec_factory.rb @@ -25,6 +25,28 @@ def from_form_params params return upload_spec end + def from_form_params_bulk params + directory = params[:splat].first + directory = ensure_ends_in_slash(directory) + files = params[:files] + files.map do |file| + upload_spec = Values::UploadSpec.new( + directory, + file[:filename], + file) + if params["allow_overwrite"] == "on" + upload_spec.set_allow_overwrite + end + unless params["default_conversion"] == "on" + upload_spec.disable_conversion + end + if params["convert_to_jpeg"] == "on" + upload_spec.set_convert_to_jpeg + end + upload_spec + end + end + def ensure_ends_in_slash directory directory_without_slashes = directory.sub(/\/+$/, '') directory_ending_in_slash = directory_without_slashes + '/' From 3d55ab89f05e77cfb1d51224268574efd2fec145 Mon Sep 17 00:00:00 2001 From: Ian Rohde Date: Wed, 18 Mar 2026 11:35:30 -0700 Subject: [PATCH 4/4] Wire up bulk upload and delete routes and views Adds routes for bulk-upload, delete, delete-dir, and their handlers. Updates browse view with bulk upload and delete links, view with a delete link, and upload form with conditional bulk mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- charge.rb | 49 ++++++++++++++++++++++++++++++++++++++++++++ lib/charge/charge.rb | 2 ++ views/browse.erb | 6 ++++-- views/upload.erb | 21 +++++++++++++++++++ views/view.erb | 3 +++ 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/charge.rb b/charge.rb index 773ff08..8ad69ea 100644 --- a/charge.rb +++ b/charge.rb @@ -11,6 +11,7 @@ require 'lib/charge/factories/upload_spec_factory' require 'lib/charge/factories/edit_spec_factory' +require 'lib/charge/factories/delete_spec_factory' SOURCE_BUCKET='ifixit-static-source' LIVE_BUCKET='ifixit-assets' @@ -100,6 +101,54 @@ def enforce_static_prefix path 'sorry, restoration not yet implemented' end +get '/bulk-upload/*' do + @directory = params[:splat].first + enforce_static_prefix @directory + @bulk = true + erb :upload +end + +post '/bulk-upload-handler/*' do + halt 400, "you must choose files to upload!" if params[:files].nil? || params[:files].empty? + upload_specs = Charge::Factories::UploadSpecFactory::from_form_params_bulk params + upload_specs.each { |spec| enforce_static_prefix spec.key } + stream do |output| + bulk_uploader = Charge::Actions::BulkUploader.new upload_specs + bulk_uploader.set_output_stream output + bulk_uploader.upload + end +end + +get '/delete/*' do + @key = params[:splat].first + enforce_static_prefix @key + @is_directory = false + @parent_directory = get_parent_dir @key + erb :delete_confirm +end + +get '/delete-dir/*' do + @key = params[:splat].first + enforce_static_prefix @key + @is_directory = true + @parent_directory = get_parent_dir @key + s3service = Charge::Config.s3service.new() + @affected_keys = s3service.all_keys_under(LIVE_BUCKET, @key) + erb :delete_confirm +end + +post '/delete-handler/*' do + key = params[:splat].first + enforce_static_prefix key + is_directory = params['is_directory'] == 'true' + delete_spec = Charge::Factories::DeleteSpecFactory::from_params(key, is_directory: is_directory) + stream do |output| + deleter = Charge::Actions::Deleter.new delete_spec + deleter.set_output_stream output + deleter.delete + end +end + get '/worst' do s3service = Charge::Config.s3service.new() @biggest = s3service.find_largest_objects(LIVE_BUCKET, BASE_PREFIX, 100) diff --git a/lib/charge/charge.rb b/lib/charge/charge.rb index 771a1f6..7e90b30 100644 --- a/lib/charge/charge.rb +++ b/lib/charge/charge.rb @@ -4,6 +4,8 @@ require 'actions/uploader' require 'actions/editor' +require 'actions/deleter' +require 'actions/bulk_uploader' require 'exceptions' diff --git a/views/browse.erb b/views/browse.erb index 46961fc..526a477 100644 --- a/views/browse.erb +++ b/views/browse.erb @@ -1,5 +1,5 @@

Browse

-

<%= @directory %> + Upload a new file here

+

<%= @directory %> + Upload a new file here | Bulk upload

diff --git a/views/upload.erb b/views/upload.erb index 8390b32..273a619 100644 --- a/views/upload.erb +++ b/views/upload.erb @@ -1,3 +1,23 @@ +<% if @bulk %> +

Bulk Upload Files

+
+
+
+
+
+
+ +
+ +
+ + +
+ +
+<% else %>

Upload a file


+<% end %> diff --git a/views/view.erb b/views/view.erb index c7005f2..bb45f5c 100644 --- a/views/view.erb +++ b/views/view.erb @@ -8,6 +8,9 @@

Edit this image

+ +

Delete this image

+

Current Live Image: