diff --git a/Gemfile b/Gemfile index e499326..c211ee6 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,7 @@ source "https://rubygems.org" gem "thor", "~> 1.3" +gem "base64" group :development, :test do gem "minitest", "~> 5.20" diff --git a/Gemfile.lock b/Gemfile.lock index 70aa513..04b9f1c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ GEM specs: addressable (2.8.8) public_suffix (>= 2.0.2, < 8.0) + base64 (0.3.0) bigdecimal (3.3.1) crack (1.0.1) bigdecimal @@ -23,6 +24,7 @@ PLATFORMS x86_64-linux DEPENDENCIES + base64 minitest (~> 5.20) rake (~> 13.0) thor (~> 1.3) diff --git a/lib/fizzy.rb b/lib/fizzy.rb index d549368..25cc6a5 100644 --- a/lib/fizzy.rb +++ b/lib/fizzy.rb @@ -5,6 +5,9 @@ require "uri" require "fileutils" require "time" +require "securerandom" +require "digest" +require "base64" require_relative "fizzy/error" require_relative "fizzy/config" @@ -22,4 +25,5 @@ require_relative "fizzy/commands/reaction" require_relative "fizzy/commands/step" require_relative "fizzy/commands/notification" +require_relative "fizzy/commands/upload" require_relative "fizzy/cli" diff --git a/lib/fizzy/cli.rb b/lib/fizzy/cli.rb index 5662875..0760ef6 100644 --- a/lib/fizzy/cli.rb +++ b/lib/fizzy/cli.rb @@ -43,5 +43,8 @@ def self.exit_on_failure? desc "notification SUBCOMMAND", "Manage notifications" subcommand "notification", Commands::Notification + + desc "upload SUBCOMMAND", "Upload files for rich text" + subcommand "upload", Commands::Upload end end diff --git a/lib/fizzy/client.rb b/lib/fizzy/client.rb index dfa813d..7ca0fb8 100644 --- a/lib/fizzy/client.rb +++ b/lib/fizzy/client.rb @@ -30,12 +30,84 @@ def put(path, body = {}) execute(uri, request) end + def post_multipart(path, params = {}, files = {}) + uri = build_uri(path) + request = Net::HTTP::Post.new(uri) + set_multipart_body(request, params, files) + execute(uri, request) + end + + def put_multipart(path, params = {}, files = {}) + uri = build_uri(path) + request = Net::HTTP::Put.new(uri) + set_multipart_body(request, params, files) + execute(uri, request) + end + def delete(path) uri = build_uri(path) request = Net::HTTP::Delete.new(uri) execute(uri, request) end + # Direct upload for rich text attachments (ActionText) + # Returns the signed_id to use in + def direct_upload(file_path) + raise Fizzy::ValidationError, "File not found: #{file_path}" unless File.exist?(file_path) + + file_content = File.binread(file_path) + filename = File.basename(file_path) + content_type = detect_content_type(file_path) + byte_size = file_content.bytesize + checksum = Base64.strict_encode64(Digest::MD5.digest(file_content)) + + # Step 1: Create direct upload + blob_params = { + blob: { + filename: filename, + byte_size: byte_size, + checksum: checksum, + content_type: content_type + } + } + + upload_info = post("/rails/active_storage/direct_uploads", blob_params) + raise Fizzy::Error, "Failed to create direct upload" unless upload_info && upload_info[:data] + + data = upload_info[:data] + direct_upload = data["direct_upload"] + raise Fizzy::Error, "No direct upload URL returned" unless direct_upload + + # Step 2: Upload file to storage + upload_uri = URI.parse(direct_upload["url"]) + upload_request = Net::HTTP::Put.new(upload_uri) + upload_request.body = file_content + + direct_upload["headers"]&.each do |key, value| + upload_request[key] = value + end + + upload_response = Net::HTTP.start(upload_uri.host, upload_uri.port, use_ssl: upload_uri.scheme == "https") do |http| + http.open_timeout = 30 + http.read_timeout = 120 + http.request(upload_request) + end + + unless upload_response.is_a?(Net::HTTPSuccess) + raise Fizzy::Error, "Failed to upload file: #{upload_response.code} #{upload_response.message}" + end + + # Return the signed_id for use in action-text-attachment + { + data: { + signed_id: data["signed_id"], + filename: data["filename"], + content_type: data["content_type"], + byte_size: data["byte_size"] + } + } + end + def account_path(path) raise ConfigError, "No account configured. Set FIZZY_ACCOUNT or use --account" unless @account "/#{@account}#{path}" @@ -142,5 +214,71 @@ def parse_link_header(header) next_url: links["next"] } end + + def set_multipart_body(request, params, files) + boundary = "----FizzyCLI#{SecureRandom.hex(16)}" + request.content_type = "multipart/form-data; boundary=#{boundary}" + + body = [] + + # Add regular params + params.each do |key, value| + if value.is_a?(Hash) + value.each do |nested_key, nested_value| + body << "--#{boundary}\r\n" + body << "Content-Disposition: form-data; name=\"#{key}[#{nested_key}]\"\r\n\r\n" + body << "#{nested_value}\r\n" + end + elsif value.is_a?(Array) + value.each do |item| + body << "--#{boundary}\r\n" + body << "Content-Disposition: form-data; name=\"#{key}[]\"\r\n\r\n" + body << "#{item}\r\n" + end + else + body << "--#{boundary}\r\n" + body << "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n" + body << "#{value}\r\n" + end + end + + # Add files + files.each do |key, file_path| + next unless file_path && File.exist?(file_path) + + filename = File.basename(file_path) + content_type = detect_content_type(file_path) + file_content = File.binread(file_path) + + body << "--#{boundary}\r\n" + body << "Content-Disposition: form-data; name=\"#{key}\"; filename=\"#{filename}\"\r\n" + body << "Content-Type: #{content_type}\r\n\r\n" + body << file_content + body << "\r\n" + end + + body << "--#{boundary}--\r\n" + request.body = body.join + end + + def detect_content_type(file_path) + extension = File.extname(file_path).downcase + case extension + when ".jpg", ".jpeg" + "image/jpeg" + when ".png" + "image/png" + when ".gif" + "image/gif" + when ".webp" + "image/webp" + when ".pdf" + "application/pdf" + when ".txt" + "text/plain" + else + "application/octet-stream" + end + end end end diff --git a/lib/fizzy/commands/card.rb b/lib/fizzy/commands/card.rb index 1c6b897..ab2fb6f 100644 --- a/lib/fizzy/commands/card.rb +++ b/lib/fizzy/commands/card.rb @@ -43,6 +43,7 @@ def show(number) option :description_file, type: :string, desc: "Read description from file" option :status, type: :string, desc: "Card status" option :tag_ids, type: :string, desc: "Comma-separated tag IDs" + option :image, type: :string, desc: "Path to header image file" def create card_params = { title: options[:title] @@ -60,7 +61,15 @@ def create card_params[:tag_ids] = options[:tag_ids].split(",").map(&:strip) end - result = client.post(client.account_path("/boards/#{options[:board]}/cards"), { card: card_params }) + result = if options[:image] + client.post_multipart( + client.account_path("/boards/#{options[:board]}/cards"), + { card: card_params }, + { "card[image]" => options[:image] } + ) + else + client.post(client.account_path("/boards/#{options[:board]}/cards"), { card: card_params }) + end output(result) rescue Fizzy::Error => e output_error(e) @@ -72,6 +81,7 @@ def create option :description_file, type: :string, desc: "Read description from file" option :status, type: :string, desc: "Card status" option :tag_ids, type: :string, desc: "Comma-separated tag IDs" + option :image, type: :string, desc: "Path to header image file" def update(number) card_params = {} card_params[:title] = options[:title] if options.key?(:title) @@ -87,7 +97,15 @@ def update(number) card_params[:tag_ids] = options[:tag_ids].split(",").map(&:strip) end - result = client.put(client.account_path("/cards/#{number}"), { card: card_params }) + result = if options[:image] + client.put_multipart( + client.account_path("/cards/#{number}"), + { card: card_params }, + { "card[image]" => options[:image] } + ) + else + client.put(client.account_path("/cards/#{number}"), { card: card_params }) + end output(result) rescue Fizzy::Error => e output_error(e) diff --git a/lib/fizzy/commands/upload.rb b/lib/fizzy/commands/upload.rb new file mode 100644 index 0000000..d4e1b5a --- /dev/null +++ b/lib/fizzy/commands/upload.rb @@ -0,0 +1,33 @@ +module Fizzy + module Commands + class Upload < Base + desc "file PATH", "Upload a file for use in rich text fields" + long_desc <<-DESC + Uploads a file using Active Storage direct upload and returns the signed_id. + + The signed_id can be used in rich text fields (card descriptions, comment bodies) + by embedding it in an action-text-attachment tag: + + + + Example workflow: + + 1. Upload the file: + $ fizzy upload file /path/to/image.png + # Returns: {"signed_id": "eyJfcmFpbHMi..."} + + 2. Use the signed_id in a card description: + $ fizzy card create --board BOARD_ID --title "My Card" \\ + --description '

See image:

' + DESC + def file(path) + raise Fizzy::ValidationError, "File not found: #{path}" unless File.exist?(path) + + result = client.direct_upload(path) + output(result) + rescue Fizzy::Error => e + output_error(e) + end + end + end +end diff --git a/lib/fizzy/commands/user.rb b/lib/fizzy/commands/user.rb index cd5f0ba..d25197a 100644 --- a/lib/fizzy/commands/user.rb +++ b/lib/fizzy/commands/user.rb @@ -28,11 +28,20 @@ def show(id) desc "update ID", "Update a user" option :name, type: :string, desc: "User name" + option :avatar, type: :string, desc: "Path to avatar image file" def update(id) user_params = {} user_params[:name] = options[:name] if options.key?(:name) - result = client.put(client.account_path("/users/#{id}"), { user: user_params }) + result = if options[:avatar] + client.put_multipart( + client.account_path("/users/#{id}"), + { user: user_params }, + { "user[avatar]" => options[:avatar] } + ) + else + client.put(client.account_path("/users/#{id}"), { user: user_params }) + end output(result) rescue Fizzy::Error => e output_error(e) diff --git a/test/fixtures/files/test_document.txt b/test/fixtures/files/test_document.txt new file mode 100644 index 0000000..b0cede2 --- /dev/null +++ b/test/fixtures/files/test_document.txt @@ -0,0 +1,2 @@ +This is a test document for Fizzy CLI file upload testing. +It contains some sample text to verify uploads work correctly. diff --git a/test/fixtures/files/test_image.png b/test/fixtures/files/test_image.png new file mode 100644 index 0000000..5c13ced Binary files /dev/null and b/test/fixtures/files/test_image.png differ diff --git a/test/fizzy/client_multipart_test.rb b/test/fizzy/client_multipart_test.rb new file mode 100644 index 0000000..429b5a2 --- /dev/null +++ b/test/fizzy/client_multipart_test.rb @@ -0,0 +1,95 @@ +require "test_helper" + +class Fizzy::ClientMultipartTest < Fizzy::TestCase + def setup + super + @client = Fizzy::Client.new( + token: "test_token", + api_url: "https://app.fizzy.do", + account: "test_account" + ) + @test_image = File.join(__dir__, "..", "fixtures", "files", "test_image.png") + end + + def test_post_multipart_sends_file + stub_request(:post, "https://app.fizzy.do/test_account/boards/5/cards") + .with { |request| + request.headers["Content-Type"].include?("multipart/form-data") && + request.body.include?("test_image.png") && + request.body.include?("card[title]") + } + .to_return( + status: 201, + body: '{"id": "1", "title": "Test"}', + headers: { "Content-Type" => "application/json" } + ) + + result = @client.post_multipart( + @client.account_path("/boards/5/cards"), + { card: { title: "Test Card" } }, + { "card[image]" => @test_image } + ) + + assert result[:data]["id"] + end + + def test_put_multipart_sends_file + stub_request(:put, "https://app.fizzy.do/test_account/users/123") + .with { |request| + request.headers["Content-Type"].include?("multipart/form-data") && + request.body.include?("test_image.png") + } + .to_return( + status: 200, + body: '{"id": "123", "name": "Test User"}', + headers: { "Content-Type" => "application/json" } + ) + + result = @client.put_multipart( + @client.account_path("/users/123"), + { user: { name: "Test User" } }, + { "user[avatar]" => @test_image } + ) + + assert result[:data]["id"] + end + + def test_multipart_skips_missing_files + stub_request(:post, "https://app.fizzy.do/test_account/cards") + .with { |request| + request.headers["Content-Type"].include?("multipart/form-data") && + !request.body.include?("nonexistent.png") + } + .to_return( + status: 201, + body: '{"id": "1"}', + headers: { "Content-Type" => "application/json" } + ) + + result = @client.post_multipart( + @client.account_path("/cards"), + { card: { title: "Test" } }, + { "card[image]" => "/nonexistent/path/file.png" } + ) + + assert result[:data]["id"] + end + + def test_detect_content_type_for_images + client = Fizzy::Client.new(token: "t", api_url: "https://test.com") + + assert_equal "image/png", client.send(:detect_content_type, "file.png") + assert_equal "image/jpeg", client.send(:detect_content_type, "file.jpg") + assert_equal "image/jpeg", client.send(:detect_content_type, "file.jpeg") + assert_equal "image/gif", client.send(:detect_content_type, "file.gif") + assert_equal "image/webp", client.send(:detect_content_type, "file.webp") + end + + def test_detect_content_type_for_documents + client = Fizzy::Client.new(token: "t", api_url: "https://test.com") + + assert_equal "application/pdf", client.send(:detect_content_type, "doc.pdf") + assert_equal "text/plain", client.send(:detect_content_type, "file.txt") + assert_equal "application/octet-stream", client.send(:detect_content_type, "file.unknown") + end +end diff --git a/test/fizzy/commands/upload_test.rb b/test/fizzy/commands/upload_test.rb new file mode 100644 index 0000000..1ecf880 --- /dev/null +++ b/test/fizzy/commands/upload_test.rb @@ -0,0 +1,85 @@ +require "test_helper" + +class Fizzy::Commands::UploadTest < Fizzy::TestCase + def setup + super + @original_home = ENV["HOME"] + @temp_dir = Dir.mktmpdir + ENV["HOME"] = @temp_dir + + config = Fizzy::Config.new + config.save!(token: "test_token", account: "test_account") + + @test_image = File.join(File.dirname(__FILE__), "..", "..", "fixtures", "files", "test_image.png") + end + + def teardown + super + ENV["HOME"] = @original_home + FileUtils.rm_rf(@temp_dir) + end + + def test_upload_file_requires_existing_file + output = capture_output do + begin + Fizzy::Commands::Upload.new.invoke(:file, ["/nonexistent/file.png"]) + rescue SystemExit + # Expected + end + end + + result = JSON.parse(output) + refute result["success"] + assert_equal "VALIDATION_ERROR", result["error"]["code"] + assert_match(/not found/i, result["error"]["message"]) + end + + def test_upload_file_creates_direct_upload + # Step 1: Create direct upload + stub_request(:post, "https://app.fizzy.do/rails/active_storage/direct_uploads") + .to_return( + status: 200, + body: { + id: "blob123", + key: "abc123", + filename: "test_image.png", + content_type: "image/png", + byte_size: 321, + checksum: "abc123==", + signed_id: "eyJfcmFpbHMi...", + direct_upload: { + url: "https://storage.example.com/upload", + headers: { + "Content-Type" => "image/png", + "Content-MD5" => "abc123==" + } + } + }.to_json, + headers: { "Content-Type" => "application/json" } + ) + + # Step 2: Upload to storage + stub_request(:put, "https://storage.example.com/upload") + .to_return(status: 200, body: "") + + output = capture_output do + Fizzy::Commands::Upload.new.invoke(:file, [@test_image]) + end + + result = JSON.parse(output) + assert result["success"] + assert_equal "eyJfcmFpbHMi...", result["data"]["signed_id"] + assert_equal "test_image.png", result["data"]["filename"] + end + + private + + def capture_output + original_stdout = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original_stdout + end +end