Created
October 13, 2025 09:46
-
-
Save bogdan/c2fd6f01fa9b61cabf0e960e0272d293 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| require 'net/http' | |
| require 'net/https' | |
| require 'json' | |
| require 'uri' | |
| class ActiveStorage::Service::PinataService < ActiveStorage::Service | |
| BASE_URL = "https://uploads.pinata.cloud" | |
| class_attribute :files, default: {}, instance_writer: false | |
| attr_reader :jwt, :gateway | |
| def initialize( | |
| gateway: "ipfs://", | |
| api_key:, | |
| api_secret:, | |
| jwt:, | |
| public:, | |
| group_id: nil | |
| ) | |
| @jwt = jwt | |
| @gateway = gateway | |
| @api_key = api_key | |
| @api_secret = api_secret | |
| @group_id = group_id | |
| @public = public | |
| end | |
| def upload(key, io, **options) | |
| instrument :upload, key: do | |
| uploaded_cid = upload_to_pinata(key, io) | |
| update_cid(key, uploaded_cid) | |
| end | |
| end | |
| def update_cid(key, cid) | |
| blob = ActiveStorage::Blob.find_by!(key:) | |
| return if blob.custom_metadata[:cid] == cid | |
| blob.custom_metadata = {**blob.custom_metadata, cid: cid} | |
| blob.save! | |
| end | |
| def download(key, &block) | |
| instrument :download, key: do | |
| uri = URI(url(key)) | |
| if Rails.env.test? | |
| cid = cid_by_key(key) | |
| if content = self.files[cid] | |
| if block | |
| return block.call(content) | |
| else | |
| return StringIO.new(self.files[cid]) | |
| end | |
| else | |
| return IPFSUtils.fixture_file(cid, &block) | |
| end | |
| end | |
| Errors.retryable(on: ActiveStorage::FileNotFoundError, interval: 3) do | |
| Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http| | |
| http.request(Net::HTTP::Get.new(uri)) do |response| | |
| unless response.is_a?(Net::HTTPSuccess) | |
| raise ActiveStorage::FileNotFoundError, "File not found at #{uri}. Code: #{response.code.to_i}. Body: #{response.body}" | |
| end | |
| if block | |
| response.read_body do |chunk| | |
| block.call(chunk) | |
| end | |
| else | |
| return StringIO.new(response.body) | |
| end | |
| end | |
| end | |
| end | |
| end | |
| end | |
| def open(key, verify: true, **options, &block) | |
| super(key, verify: verify && !Rails.env.test?, **options, &block) | |
| end | |
| def delete(key) | |
| # Unpinning should not happen | |
| # Because we guarantee files availability to our clients | |
| # instrument :delete, key: do | |
| # uri = URI("#{PINATA_BASE_URL}/pinning/unpin") | |
| # request = Net::HTTP::Post.new(uri, headers) | |
| # request.body = { ipfs_pin_hash: key }.to_json | |
| # response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) } | |
| # raise ActiveStorage::FileNotFoundError, "Delete failed: #{response.body}" unless response.is_a?(Net::HTTPSuccess) | |
| # end | |
| end | |
| def url(key, cid: nil, **options) | |
| url = gateway_url(cid || key) | |
| unless url | |
| raise ActiveStorage::IntegrityError, "Ipfs cid is not specified for #{key.inspect}" | |
| end | |
| url | |
| end | |
| def exist?(key) | |
| instrument :exists, key: do | |
| url = gateway_url(key) | |
| return false unless url | |
| if Rails.env.test? | |
| return !!self.class.files[IPFSUtils.normalize_cid(url)] | |
| end | |
| uri = URI(url) | |
| http = Net::HTTP.new(uri.host, uri.port) | |
| http.use_ssl = true | |
| response = http.head(uri.path) | |
| response.is_a?(Net::HTTPSuccess) | |
| end | |
| end | |
| def public? | |
| @public | |
| end | |
| def network | |
| public? ? 'public' : 'private' | |
| end | |
| private | |
| def upload_to_pinata(key, io) | |
| if Rails.env.test? | |
| cid = IPFSUtils.calculate_cid_v1(io) | |
| unless self.class.files[cid] | |
| self.class.files[cid] = io.read | |
| end | |
| return cid | |
| end | |
| uri = URI("#{BASE_URL}/v3/files") | |
| request = Net::HTTP::Post.new(uri) | |
| request["Authorization"] = "Bearer #{jwt}" | |
| form_data = [ | |
| ["network", network], | |
| ["file", io], | |
| ] | |
| # Optional metadata | |
| form_data << ["group_id", @group_id] if @group_id | |
| form_data << ["keyvalues", { key: }.to_json] | |
| request.set_form(form_data, 'multipart/form-data') | |
| Errors.retryable(on: [IOError, Net::OpenTimeout]) do | |
| response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| | |
| http.request(request) | |
| end | |
| raise ActiveStorage::IntegrityError, "Upload failed: #{response.body}" unless response.is_a?(Net::HTTPSuccess) | |
| json = JSON.parse(response.body) | |
| json.dig("data", "cid") || raise(ActiveStorage::IntegrityError, "Upload response missing CID: #{response.body}") | |
| end | |
| end | |
| def gateway_url(value) | |
| cid = cid_by_key(value) | |
| return nil unless cid | |
| unless IPFSUtils.valid?(cid) | |
| raise ActiveStorage::IntegrityError, "Blob metadata CID #{cid.inspect} is invalid" | |
| end | |
| [@gateway, @gateway.end_with?('/') ? "" : "/", cid].join('') | |
| end | |
| def cid_by_key(key) | |
| IPFSUtils.valid?(key) ? key : ActiveStorage::Blob.where(key:).pick(:metadata)&.fetch(:custom, nil)&.fetch(:cid, nil) | |
| end | |
| end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment