#!/usr/bin/env ruby # Ruby 3.1+; credentials stay on your server. No gems required. require 'json' require 'net/http' require 'set' require 'timeout' require 'uri' def monotonic_time Process.clock_gettime(Process::CLOCK_MONOTONIC) end def encode_path_segment(value) URI.encode_www_form_component(value.to_s).gsub('+', '%20') end class PreparationTimeout < StandardError; end class ScormboxApi RETRY_STATUSES = [409, 429, 500, 502, 503, 504].freeze def initialize(base_url, api_key, api_secret) @base_url = base_url.sub(%r{/+\z}, '') service = URI.parse(@base_url) unless service.host && (service.scheme == 'https' || (service.scheme == 'http' && service.host.downcase == 'localhost')) raise 'Use HTTPS for server credentials, or HTTP localhost for development.' end if service.userinfo || service.query || service.fragment raise 'The API base URL must not include credentials, a query, or a fragment.' end @authorization = 'Basic ' + [api_key + ':' + api_secret].pack('m0') end def request(path, method: 'GET', body: nil, headers: {}, deadline: nil) binary = body.is_a?(String) payload = body.nil? ? nil : (binary ? body : JSON.generate(body)) uri = URI.parse(@base_url + path) retry_number = 0 loop do remaining = deadline ? deadline - monotonic_time : 120 raise PreparationTimeout if remaining <= 0 timeout = [120, remaining].min request = Net::HTTPGenericRequest.new(method, !payload.nil?, true, uri.request_uri) request['Authorization'] = @authorization request['Content-Type'] = binary ? 'application/octet-stream' : 'application/json' headers.each { |name, value| request[name] = value } unless payload.nil? request.body = payload request['Content-Length'] = payload.bytesize.to_s end begin http = Net::HTTP.new(uri.host, uri.port, nil) http.use_ssl = uri.scheme == 'https' http.open_timeout = timeout http.read_timeout = timeout http.write_timeout = timeout http.max_retries = 0 response = Timeout.timeout(timeout) { http.request(request) } rescue IOError, SystemCallError, SocketError, Timeout::Error, Net::HTTPBadResponse, OpenSSL::SSL::SSLError => error raise PreparationTimeout if deadline && monotonic_time >= deadline raise error if retry_number >= 4 wait_to_retry(retry_number, deadline) retry_number += 1 next end status = response.code.to_i raise "HTTP #{status}: Redirects are not allowed." if (300..399).cover?(status) if RETRY_STATUSES.include?(status) && retry_number < 4 wait_to_retry(retry_number, deadline) retry_number += 1 next end result = JSON.parse(response.body) raise 'The API returned a JSON value instead of an object.' unless result.is_a?(Hash) return result if (200..299).cover?(status) raise "HTTP #{status}: #{result['error'] || 'Request failed.'}" end end private def wait_to_retry(retry_number, deadline) delay = 2**retry_number if deadline remaining = deadline - monotonic_time raise PreparationTimeout if remaining <= 0 delay = [delay, remaining].min end sleep(delay) end end begin filename = ARGV[0] raise "Usage: ruby #{File.basename($PROGRAM_NAME)} path/to/course.zip" unless filename api_key = ENV['SCORMBOX_API_KEY'] api_secret = ENV['SCORMBOX_API_SECRET'] idempotency_key = ENV['SCORMBOX_IDEMPOTENCY_KEY'] if [api_key, api_secret, idempotency_key].any? { |value| value.nil? || value.empty? } raise 'Set SCORMBOX_API_KEY, SCORMBOX_API_SECRET and a stable SCORMBOX_IDEMPOTENCY_KEY for this content version.' end base_url = ENV['SCORMBOX_API_BASE_URL'] base_url = 'https://scormbox.com/api/v1' if base_url.nil? || base_url.empty? api = ScormboxApi.new(base_url, api_key, api_secret) size = File.size(filename) options = { 'filename' => File.basename(filename), 'size' => size, 'shareable' => ENV['SCORMBOX_SHAREABLE'] == 'true' } title = ENV['SCORMBOX_TITLE'] options['title'] = title if title && !title.empty? course = api.request('/courses', method: 'POST', body: options, headers: { 'Idempotency-Key' => idempotency_key }) if %w[awaiting_upload uploading].include?(course['status']) upload = course.fetch('upload') upload_path = '/uploads/' + encode_path_segment(upload.fetch('upload_id')) part_size = upload.fetch('part_size') part_count = upload.fetch('part_count') unless part_size.is_a?(Integer) && part_size.positive? && part_count.is_a?(Integer) && part_count == (size + part_size - 1) / part_size raise 'The API returned invalid upload part sizes.' end saved_parts = Set.new(upload.fetch('parts').map { |part| part.fetch('part_number') }) File.open(filename, 'rb') do |file| bytes = String.new(encoding: Encoding::BINARY) (1..part_count).each do |part_number| next if saved_parts.include?(part_number) offset = (part_number - 1) * part_size length = [part_size, size - offset].min file.seek(offset) unless file.read(length, bytes) && bytes.bytesize == length raise 'The package changed while being read.' end api.request("#{upload_path}/parts/#{part_number}", method: 'PUT', body: bytes) puts "Uploaded part #{part_number} of #{part_count}" end end api.request(upload_path + '/complete', method: 'POST', body: {}) end course_id = course.fetch('course_id') deadline = monotonic_time + 20 * 60 loop do course = api.request('/courses/' + encode_path_segment(course_id), deadline: deadline) break if course['status'] == 'ready' raise(course['error'] || 'Package preparation failed.') if course['status'] == 'failed' preparation = course.fetch('preparation') puts "Course #{course['status']}: #{preparation['completed_files']} of #{preparation['total_files']} files" remaining = deadline - monotonic_time raise PreparationTimeout if remaining <= 0 sleep([2, remaining].min) end puts JSON.pretty_generate({ 'course_id' => course.fetch('course_id'), 'format' => course.fetch('format'), 'management_url' => course.fetch('management_url'), 'learner_url' => course.fetch('learner_url') }) rescue PreparationTimeout warn "Preparation is still running. Check course_id #{course_id} before trying again." exit 1 rescue StandardError => error warn error.message exit 1 end