// .NET 10+ file-based app; credentials stay on your server. No NuGet packages required. // Run: dotnet run --file scormbox-upload.cs -- /path/to/course.zip #:property PublishAot=false using System.Net.Http.Headers; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; try { if (args.Length != 1) throw new Exception("Usage: dotnet run --file " + sourceFilename() + " -- /path/to/course.zip"); await uploadCourse(args[0]); return 0; } catch (Exception error) { Console.Error.WriteLine("Error: " + error.Message); return 1; } static string sourceFilename([CallerFilePath] string source_path = "") => Path.GetFileName(source_path); static async Task uploadCourse(string filename) { var api_key = Environment.GetEnvironmentVariable("SCORMBOX_API_KEY"); var api_secret = Environment.GetEnvironmentVariable("SCORMBOX_API_SECRET"); var idempotency_key = Environment.GetEnvironmentVariable("SCORMBOX_IDEMPOTENCY_KEY"); if (string.IsNullOrEmpty(api_key) || string.IsNullOrEmpty(api_secret) || string.IsNullOrEmpty(idempotency_key)) throw new Exception("Set SCORMBOX_API_KEY, SCORMBOX_API_SECRET and a stable SCORMBOX_IDEMPOTENCY_KEY for this content version."); var base_url = Environment.GetEnvironmentVariable("SCORMBOX_API_BASE_URL"); if (string.IsNullOrEmpty(base_url)) base_url = "https://scormbox.com/api/v1"; if (!Uri.TryCreate(base_url, UriKind.Absolute, out var service) || (service.Scheme != "https" && !(service.Scheme == "http" && service.Host == "localhost"))) throw new Exception("Use HTTPS for server credentials, or HTTP localhost for development."); if (!string.IsNullOrEmpty(service.UserInfo) || !string.IsNullOrEmpty(service.Query) || !string.IsNullOrEmpty(service.Fragment)) throw new Exception("Use an API base URL without credentials, a query or a fragment."); base_url = base_url.TrimEnd('/'); using var handler = new HttpClientHandler { AllowAutoRedirect = false }; using var client = new HttpClient(handler) { Timeout = TimeSpan.FromMinutes(2) }; var authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(api_key + ":" + api_secret))); async Task request(string path, HttpMethod method, object? body = null, byte[]? chunk = null, int length = 0, string? idempotency = null, CancellationToken cancellation = default) { // Creation has an idempotency key; parts, completion and reads are safe to retry. for (var retry = 0; ; retry++) { cancellation.ThrowIfCancellationRequested(); try { using var operation = new HttpRequestMessage(method, base_url + path); operation.Headers.Authorization = authorization; if (idempotency != null) operation.Headers.Add("Idempotency-Key", idempotency); if (chunk != null) { operation.Content = new ByteArrayContent(chunk, 0, length); operation.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); operation.Content.Headers.ContentLength = length; } else { operation.Content = new StringContent(body == null ? "" : JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); } using var response = await client.SendAsync(operation, cancellation); var status = (int)response.StatusCode; if (status >= 300 && status < 400) throw new Exception("HTTP " + status + ": redirects are refused."); var payload = await response.Content.ReadAsStringAsync(cancellation); JsonElement result = default; try { using var document = JsonDocument.Parse(payload); if (document.RootElement.ValueKind != JsonValueKind.Object) throw new JsonException("Expected a JSON object"); result = document.RootElement.Clone(); } catch (JsonException) { if (response.IsSuccessStatusCode) throw new Exception("HTTP " + status + ": invalid JSON response."); } if (response.IsSuccessStatusCode) return result; if (retry == 4 || status is not (409 or 429 or 500 or 502 or 503 or 504)) { var error = result.ValueKind == JsonValueKind.Object && result.TryGetProperty("error", out var message) ? message.ToString() : "Request failed."; throw new Exception("HTTP " + status + ": " + error); } } catch (HttpRequestException) when (retry < 4) { } catch (OperationCanceledException) when (!cancellation.IsCancellationRequested && retry < 4) { } await Task.Delay(TimeSpan.FromSeconds(1 << retry), cancellation); // Four retries: 1, 2, 4 and 8 seconds. } } var size = new FileInfo(filename).Length; var properties = new Dictionary { ["filename"] = Path.GetFileName(filename), ["size"] = size, ["shareable"] = Environment.GetEnvironmentVariable("SCORMBOX_SHAREABLE") == "true" }; var title = Environment.GetEnvironmentVariable("SCORMBOX_TITLE"); if (!string.IsNullOrEmpty(title)) properties["title"] = title; var course = await request("/courses", HttpMethod.Post, properties, idempotency: idempotency_key); var status = course.GetProperty("status").GetString(); if (status is "awaiting_upload" or "uploading") { var upload = course.GetProperty("upload"); var upload_id = upload.GetProperty("upload_id").GetString()!; var part_size = upload.GetProperty("part_size").GetInt32(); var part_count = upload.GetProperty("part_count").GetInt32(); var saved = upload.GetProperty("parts").EnumerateArray() .Select(part => part.GetProperty("part_number").GetInt32()).ToHashSet(); var upload_path = "/uploads/" + Uri.EscapeDataString(upload_id); var chunk = new byte[part_size]; // Reuse one part buffer, including for retries. await using (var file = new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, options: FileOptions.Asynchronous)) { for (var part_number = 1; part_number <= part_count; part_number++) { if (saved.Contains(part_number)) continue; var offset = (long)(part_number - 1) * part_size; var length = (int)Math.Min(part_size, size - offset); file.Position = offset; try { await file.ReadExactlyAsync(chunk.AsMemory(0, length)); } catch (EndOfStreamException) { throw new Exception("The package changed while being read."); } await request(upload_path + "/parts/" + part_number, HttpMethod.Put, chunk: chunk, length: length); Console.WriteLine("Uploaded part " + part_number + " of " + part_count); } } await request(upload_path + "/complete", HttpMethod.Post, new { }); } var course_id = course.GetProperty("course_id").GetString()!; using var preparation_timeout = new CancellationTokenSource(TimeSpan.FromMinutes(20)); try { for (;;) { course = await request("/courses/" + Uri.EscapeDataString(course_id), HttpMethod.Get, cancellation: preparation_timeout.Token); status = course.GetProperty("status").GetString(); if (status == "ready") break; if (status == "failed") throw new Exception(course.GetProperty("error").GetString() ?? "Package preparation failed."); var completed_files = 0; var total_files = 0; if (course.TryGetProperty("preparation", out var preparation) && preparation.ValueKind == JsonValueKind.Object) { completed_files = preparation.GetProperty("completed_files").GetInt32(); total_files = preparation.GetProperty("total_files").GetInt32(); } Console.WriteLine("Course " + status + ": " + completed_files + " of " + total_files + " files"); await Task.Delay(TimeSpan.FromSeconds(2), preparation_timeout.Token); } } catch (OperationCanceledException) when (preparation_timeout.IsCancellationRequested) { throw new Exception("Preparation is still running. Check course_id " + course_id + " before trying again."); } Console.WriteLine(JsonSerializer.Serialize(new { course_id = course.GetProperty("course_id"), format = course.GetProperty("format"), management_url = course.GetProperty("management_url"), learner_url = course.GetProperty("learner_url") }, new JsonSerializerOptions { WriteIndented = true })); }