Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
source "https://rubygems.org"

gem "thor", "~> 1.3"
gem "base64"

group :development, :test do
gem "minitest", "~> 5.20"
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +24,7 @@ PLATFORMS
x86_64-linux

DEPENDENCIES
base64
minitest (~> 5.20)
rake (~> 13.0)
thor (~> 1.3)
Expand Down
4 changes: 4 additions & 0 deletions lib/fizzy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
require "uri"
require "fileutils"
require "time"
require "securerandom"
require "digest"
require "base64"

require_relative "fizzy/error"
require_relative "fizzy/config"
Expand All @@ -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"
3 changes: 3 additions & 0 deletions lib/fizzy/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
138 changes: 138 additions & 0 deletions lib/fizzy/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <action-text-attachment sgid="...">
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}"
Expand Down Expand Up @@ -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
22 changes: 20 additions & 2 deletions lib/fizzy/commands/card.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions lib/fizzy/commands/upload.rb
Original file line number Diff line number Diff line change
@@ -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:

<action-text-attachment sgid="SIGNED_ID"></action-text-attachment>

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 '<p>See image:</p><action-text-attachment sgid="eyJfcmFpbHMi..."></action-text-attachment>'
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
11 changes: 10 additions & 1 deletion lib/fizzy/commands/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions test/fixtures/files/test_document.txt
Original file line number Diff line number Diff line change
@@ -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.
Binary file added test/fixtures/files/test_image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading