// Java 17+; credentials stay on your server. No Maven or Gradle dependencies. // Run: java ./scormbox-upload.java /path/to/course.zip import java.io.ByteArrayInputStream; import java.io.EOFException; import java.io.IOException; import java.io.RandomAccessFile; import java.math.BigDecimal; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.ArrayList; import java.util.Base64; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; class ScormboxUpload { private final String base_url; private final String authorization; private final String idempotency_key; private final HttpClient client; private static final Set RETRYABLE = Set.of(409, 429, 500, 502, 503, 504); public static void main(String[] args) { try { if (args.length != 1) { var source = System.getProperty("jdk.launcher.sourcefile", "scormbox-upload.java"); throw new IllegalArgumentException("Usage: java ./" + Path.of(source).getFileName() + " /path/to/course.zip"); } new ScormboxUpload().uploadCourse(Path.of(args[0])); } catch (Exception error) { if (error instanceof InterruptedException) Thread.currentThread().interrupt(); System.err.println("Error: " + error.getMessage()); System.exit(1); } } ScormboxUpload() { var api_key = environment("SCORMBOX_API_KEY"); var api_secret = environment("SCORMBOX_API_SECRET"); idempotency_key = environment("SCORMBOX_IDEMPOTENCY_KEY"); if (api_key.isEmpty() || api_secret.isEmpty() || idempotency_key.isEmpty()) throw new IllegalArgumentException("Set SCORMBOX_API_KEY, SCORMBOX_API_SECRET and a stable SCORMBOX_IDEMPOTENCY_KEY for this content version."); var configured_url = environment("SCORMBOX_API_BASE_URL"); if (configured_url.isEmpty()) configured_url = "https://scormbox.com/api/v1"; var service = URI.create(configured_url); if (service.getHost() == null || !("https".equals(service.getScheme()) || ("http".equals(service.getScheme()) && "localhost".equals(service.getHost())))) throw new IllegalArgumentException("Use HTTPS for server credentials, or HTTP localhost for development."); if (service.getUserInfo() != null || service.getQuery() != null || service.getFragment() != null) throw new IllegalArgumentException("Use an API base URL without credentials, a query or a fragment."); while (configured_url.endsWith("/")) configured_url = configured_url.substring(0, configured_url.length() - 1); base_url = configured_url; authorization = "Basic " + Base64.getEncoder().encodeToString((api_key + ":" + api_secret).getBytes(StandardCharsets.UTF_8)); client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER) .connectTimeout(Duration.ofMinutes(2)).version(HttpClient.Version.HTTP_1_1).build(); } private static String environment(String name) { var value = System.getenv(name); return value == null ? "" : value; } private static String encodePath(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); } private void uploadCourse(Path filename) throws Exception { long size = Files.size(filename); var properties = new LinkedHashMap(); properties.put("filename", filename.getFileName().toString()); properties.put("size", size); properties.put("shareable", "true".equals(environment("SCORMBOX_SHAREABLE"))); var title = environment("SCORMBOX_TITLE"); if (!title.isEmpty()) properties.put("title", title); var course = request("/courses", "POST", properties, idempotency_key, 0); var status = string(course, "status"); if (status.equals("awaiting_upload") || status.equals("uploading")) { var upload = object(course.get("upload")); var upload_path = "/uploads/" + encodePath(string(upload, "upload_id")); int part_size = integer(upload, "part_size"), part_count = integer(upload, "part_count"); if (part_size <= 0 || part_count <= 0) throw new IllegalArgumentException("Invalid upload part sizes."); var saved = new HashSet(); if (!(upload.get("parts") instanceof List parts)) throw new IllegalArgumentException("Invalid upload receipts."); for (var part : parts) saved.add(integer(object(part), "part_number")); var chunk = new byte[part_size]; // Reuse one part buffer, including for retries. try (var file = new RandomAccessFile(filename.toFile(), "r")) { for (int part_number = 1; part_number <= part_count; part_number++) { if (saved.contains(part_number)) continue; long offset = (long) (part_number - 1) * part_size; int length = (int) Math.min(part_size, size - offset); if (length <= 0) throw new IllegalArgumentException("Invalid upload part sizes."); file.seek(offset); try { file.readFully(chunk, 0, length); } catch (EOFException error) { throw new IOException("The package changed while being read.", error); } request(upload_path + "/parts/" + part_number, "PUT", new Part(chunk, length), null, 0); System.out.println("Uploaded part " + part_number + " of " + part_count); } } request(upload_path + "/complete", "POST", Map.of(), null, 0); } var course_id = string(course, "course_id"); long deadline = System.nanoTime() + Duration.ofMinutes(20).toNanos(); for (;;) { if (System.nanoTime() >= deadline) throw new IOException("Preparation is still running. Check course_id " + course_id + " before trying again."); course = request("/courses/" + encodePath(course_id), "GET", null, null, deadline); status = string(course, "status"); if (status.equals("ready")) break; if (status.equals("failed")) { var error = course.get("error"); throw new IOException(error == null ? "Package preparation failed." : error.toString()); } var preparation = course.get("preparation") == null ? Map.of() : object(course.get("preparation")); System.out.println("Course " + status + ": " + preparation.getOrDefault("completed_files", 0) + " of " + preparation.getOrDefault("total_files", 0) + " files"); pause(2000, deadline); } var result = new LinkedHashMap(); for (var field : List.of("course_id", "format", "management_url", "learner_url")) result.put(field, course.get(field)); System.out.println(Json.write(result)); } private record Part(byte[] bytes, int length) { } private Map request(String path, String method, Object body, String idempotency, long deadline) throws IOException, InterruptedException { // Creation has an idempotency key; parts, completion and reads are safe to retry. for (int retry = 0; ; retry++) { long remaining = deadline == 0 ? Duration.ofMinutes(2).toNanos() : deadline - System.nanoTime(); if (remaining <= 0) throw new IOException("Preparation timed out; check the course_id before trying again."); var timeout = Duration.ofNanos(Math.min(Duration.ofMinutes(2).toNanos(), remaining)); HttpRequest.BodyPublisher publisher; if (body instanceof Part part) { // The known length makes HttpClient send exact Content-Length. The stream shares our buffer. publisher = HttpRequest.BodyPublishers.fromPublisher(HttpRequest.BodyPublishers.ofInputStream( () -> new ByteArrayInputStream(part.bytes(), 0, part.length())), part.length()); } else { publisher = body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(Json.write(body)); } var operation = HttpRequest.newBuilder(URI.create(base_url + path)).timeout(timeout) .header("Authorization", authorization) .header("Content-Type", body instanceof Part ? "application/octet-stream" : "application/json") .method(method, publisher); if (idempotency != null) operation.header("Idempotency-Key", idempotency); HttpResponse response; try { response = send(operation.build(), timeout); } catch (IOException error) { if (retry == 4) throw new IOException("Request failed after retries: " + error.getMessage(), error); pause(1000L << retry, deadline); continue; } int status = response.statusCode(); if (status >= 300 && status < 400) throw new IOException("HTTP " + status + ": redirects are refused."); Map result; try { result = object(Json.parse(response.body())); } catch (IllegalArgumentException error) { if (status >= 200 && status < 300) throw new IOException("HTTP " + status + ": invalid JSON response.", error); result = Map.of(); } if (status >= 200 && status < 300) return result; if (retry == 4 || !RETRYABLE.contains(status)) throw new IOException("HTTP " + status + ": " + result.getOrDefault("error", "Request failed.")); pause(1000L << retry, deadline); // Four retries: 1, 2, 4 and 8 seconds. } } private HttpResponse send(HttpRequest operation, Duration timeout) throws IOException, InterruptedException { var pending = client.sendAsync(operation, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); try { // Bound the complete response body, as well as connecting and receiving its headers. return pending.get(timeout.toNanos(), TimeUnit.NANOSECONDS); } catch (TimeoutException error) { pending.cancel(true); throw new IOException("Request timed out.", error); } catch (ExecutionException error) { if (error.getCause() instanceof IOException cause) throw cause; throw new IllegalStateException("Request failed.", error.getCause()); } catch (InterruptedException error) { pending.cancel(true); throw error; } } private static void pause(long milliseconds, long deadline) throws InterruptedException { long nanos = TimeUnit.MILLISECONDS.toNanos(milliseconds); if (deadline != 0) nanos = Math.min(nanos, Math.max(0, deadline - System.nanoTime())); TimeUnit.NANOSECONDS.sleep(nanos); } @SuppressWarnings("unchecked") private static Map object(Object value) { if (!(value instanceof Map)) throw new IllegalArgumentException("Expected a JSON object."); return (Map) value; } private static String string(Map value, String field) { if (!(value.get(field) instanceof String result)) throw new IllegalArgumentException("Invalid response field: " + field); return result; } private static int integer(Map value, String field) { if (!(value.get(field) instanceof BigDecimal result)) throw new IllegalArgumentException("Invalid response field: " + field); return result.intValueExact(); } // A small JSON reader/writer for API responses and requests; no external JSON library is needed. private static final class Json { private final String source; private int position; private Json(String source) { this.source = source; } static Object parse(String source) { var reader = new Json(source); var result = reader.value(0); reader.whitespace(); if (reader.position != source.length()) throw reader.invalid(); return result; } private Object value(int depth) { if (depth > 128) throw invalid(); whitespace(); if (position >= source.length()) throw invalid(); char next = source.charAt(position); if (next == '"') return text(); if (next == '{') { position++; var result = new LinkedHashMap(); whitespace(); if (consume('}')) return result; for (;;) { whitespace(); var key = text(); whitespace(); require(':'); result.put(key, value(depth + 1)); whitespace(); if (consume('}')) return result; require(','); } } if (next == '[') { position++; var result = new ArrayList(); whitespace(); if (consume(']')) return result; for (;;) { result.add(value(depth + 1)); whitespace(); if (consume(']')) return result; require(','); } } if (next == 't') return literal("true", true); if (next == 'f') return literal("false", false); if (next == 'n') return literal("null", null); return number(); } private Object literal(String text, Object result) { if (!source.startsWith(text, position)) throw invalid(); position += text.length(); return result; } private BigDecimal number() { int start = position; consume('-'); if (!consume('0')) { if (position >= source.length() || source.charAt(position) < '1' || source.charAt(position) > '9') throw invalid(); digits(); } if (consume('.')) digits(); if (consume('e') || consume('E')) { if (!consume('+')) consume('-'); digits(); } try { return new BigDecimal(source.substring(start, position)); } catch (NumberFormatException error) { throw invalid(); } } private void digits() { int start = position; while (position < source.length() && source.charAt(position) >= '0' && source.charAt(position) <= '9') position++; if (start == position) throw invalid(); } private String text() { require('"'); var result = new StringBuilder(); while (position < source.length()) { char character = source.charAt(position++); if (character == '"') return result.toString(); if (character < 0x20) throw invalid(); if (character != '\\') { result.append(character); continue; } if (position >= source.length()) throw invalid(); char escape = source.charAt(position++); switch (escape) { case '"', '\\', '/' -> result.append(escape); case 'b' -> result.append('\b'); case 'f' -> result.append('\f'); case 'n' -> result.append('\n'); case 'r' -> result.append('\r'); case 't' -> result.append('\t'); case 'u' -> { if (position + 4 > source.length()) throw invalid(); int code = 0; for (int count = 0; count < 4; count++) { char digit = source.charAt(position++); int hex = digit >= '0' && digit <= '9' ? digit - '0' : digit >= 'a' && digit <= 'f' ? digit - 'a' + 10 : digit >= 'A' && digit <= 'F' ? digit - 'A' + 10 : -1; if (hex < 0) throw invalid(); code = code * 16 + hex; } result.append((char) code); } default -> throw invalid(); } } throw invalid(); } private void whitespace() { while (position < source.length() && " \t\r\n".indexOf(source.charAt(position)) >= 0) position++; } private boolean consume(char expected) { if (position < source.length() && source.charAt(position) == expected) { position++; return true; } return false; } private void require(char expected) { if (!consume(expected)) throw invalid(); } private IllegalArgumentException invalid() { return new IllegalArgumentException("Invalid JSON at character " + position); } static String write(Object value) { if (value == null) return "null"; if (value instanceof Boolean || value instanceof Number) { var result = value.toString(); if (value instanceof Number) new BigDecimal(result); // Reject NaN and infinity. return result; } if (value instanceof String text) { var result = new StringBuilder("\""); for (int index = 0; index < text.length(); index++) { char character = text.charAt(index); switch (character) { case '"' -> result.append("\\\""); case '\\' -> result.append("\\\\"); case '\b' -> result.append("\\b"); case '\f' -> result.append("\\f"); case '\n' -> result.append("\\n"); case '\r' -> result.append("\\r"); case '\t' -> result.append("\\t"); default -> { if (character < 0x20 || Character.isSurrogate(character)) result.append(String.format("\\u%04x", (int) character)); else result.append(character); } } } return result.append('"').toString(); } if (value instanceof Map map) { var result = new StringBuilder("{"); for (var entry : map.entrySet()) { if (!(entry.getKey() instanceof String)) throw new IllegalArgumentException("JSON object keys must be strings."); if (result.length() > 1) result.append(','); result.append(write(entry.getKey())).append(':').append(write(entry.getValue())); } return result.append('}').toString(); } if (value instanceof List list) { var result = new StringBuilder("["); for (var entry : list) { if (result.length() > 1) result.append(','); result.append(write(entry)); } return result.append(']').toString(); } throw new IllegalArgumentException("Unsupported JSON value."); } } }