From f698f64d38c5d40350ba0afdc384ed50f2784707 Mon Sep 17 00:00:00 2001
From: Marius Kleidl <1375043+Acconut@users.noreply.github.com>
Date: Thu, 28 Nov 2024 11:45:48 +0100
Subject: [PATCH] Add method for generating signed Smart CDN URLs (#92)
* Add method for generating signed Smart CDN URLs
* Fix linter
* Avoid using newer API
* Support duplicate URL params
* Fix Linting Errors
* Fix Linting Errors
* Fix Line Length Constraint in Test-File
* Use absolute expiration timestamp
---------
Co-authored-by: florian <60937022+cdr-chakotay@users.noreply.github.com>
---
.../java/com/transloadit/sdk/Transloadit.java | 83 +++++++++++++++++++
.../com/transloadit/sdk/TransloaditTest.java | 33 ++++++--
2 files changed, 110 insertions(+), 6 deletions(-)
diff --git a/src/main/java/com/transloadit/sdk/Transloadit.java b/src/main/java/com/transloadit/sdk/Transloadit.java
index acaf2d4..530d51b 100644
--- a/src/main/java/com/transloadit/sdk/Transloadit.java
+++ b/src/main/java/com/transloadit/sdk/Transloadit.java
@@ -8,15 +8,28 @@
import com.transloadit.sdk.response.AssemblyResponse;
import com.transloadit.sdk.response.ListResponse;
import com.transloadit.sdk.response.Response;
+import org.apache.commons.codec.binary.Hex;
+import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.io.InputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import java.util.Properties;
+import java.util.SortedMap;
+import java.util.TreeMap;
/**
* This class serves as a client interface to the Transloadit API.
@@ -430,4 +443,74 @@ public void setRetryDelay(int delay) throws LocalOperationException {
this.retryDelay = delay;
}
}
+
+ /**
+ * Construct a signed Smart CDN URL.
+ * See the API documentation.
+ * Same as {@link Transloadit#getSignedSmartCDNUrl(String, String, String, Map, long)}, but with an expiration in 1 hour.
+ *
+ * @param workspace Workspace slug
+ * @param template Template slug or template ID
+ * @param input Input value that is provided as ${fields.input} in the template
+ * @param urlParams Additional parameters for the URL query string (optional)
+ * @return The signed Smart CDN URL
+ */
+ public String getSignedSmartCDNUrl(@NotNull String workspace, @NotNull String template, @NotNull String input,
+ @Nullable Map> urlParams) throws LocalOperationException {
+ // 1 hours default expiration
+ long expiresAt = Instant.now().toEpochMilli() + 60 * 60 * 1000;
+ return getSignedSmartCDNUrl(workspace, template, input, urlParams, expiresAt);
+ }
+
+ /**
+ * Construct a signed Smart CDN URL.
+ * See the API documentation.
+ *
+ * @param workspace Workspace slug
+ * @param template Template slug or template ID
+ * @param input Input value that is provided as ${fields.input} in the template
+ * @param urlParams Additional parameters for the URL query string (optional)
+ * @param expiresAt Expiration timestamp of the signature in milliseconds since the UNIX epoch.
+ * @return The signed Smart CDN URL
+ */
+ public String getSignedSmartCDNUrl(@NotNull String workspace, @NotNull String template, @NotNull String input,
+ @Nullable Map> urlParams, long expiresAt) throws LocalOperationException {
+
+ try {
+ String workspaceSlug = URLEncoder.encode(workspace, StandardCharsets.UTF_8.name());
+ String templateSlug = URLEncoder.encode(template, StandardCharsets.UTF_8.name());
+ String inputField = URLEncoder.encode(input, StandardCharsets.UTF_8.name());
+
+ // Use TreeMap to ensure keys in URL params are sorted.
+ SortedMap> params = new TreeMap<>(urlParams);
+ params.put("auth_key", Collections.singletonList(this.key));
+ params.put("exp", Collections.singletonList(String.valueOf(expiresAt)));
+
+ List queryParts = new ArrayList<>(params.size());
+ for (Map.Entry> entry : params.entrySet()) {
+ String key = entry.getKey();
+ for (String value : entry.getValue()) {
+ queryParts.add(URLEncoder.encode(key, StandardCharsets.UTF_8.name()) + "=" + URLEncoder.encode(
+ value, StandardCharsets.UTF_8.name()));
+ }
+ }
+
+ String queryString = String.join("&", queryParts);
+ String stringToSign = workspaceSlug + "/" + templateSlug + "/" + inputField + "?" + queryString;
+
+ Mac hmac = Mac.getInstance("HmacSHA256");
+ SecretKeySpec secretKey = new SecretKeySpec(
+ this.secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
+ hmac.init(secretKey);
+ byte[] signatureBytes = hmac.doFinal(stringToSign.getBytes());
+ byte[] signatureHexBytes = new Hex().encode((signatureBytes));
+ String signature = "sha256:" + new String(signatureHexBytes, StandardCharsets.UTF_8);
+ String signatureEncoded = URLEncoder.encode(signature, StandardCharsets.UTF_8.name());
+
+ return "https://" + workspaceSlug + ".tlcdn.com/" + templateSlug + "/"
+ + inputField + "?" + queryString + "&sig=" + signatureEncoded;
+ } catch (UnsupportedEncodingException | NoSuchAlgorithmException | InvalidKeyException e) {
+ throw new LocalOperationException("Failed to create signature: " + e.getMessage());
+ }
+ }
}
diff --git a/src/test/java/com/transloadit/sdk/TransloaditTest.java b/src/test/java/com/transloadit/sdk/TransloaditTest.java
index 04c50af..92e6f1b 100644
--- a/src/test/java/com/transloadit/sdk/TransloaditTest.java
+++ b/src/test/java/com/transloadit/sdk/TransloaditTest.java
@@ -16,15 +16,15 @@
import org.mockserver.model.HttpResponse;
import java.io.IOException;
-import java.util.HashMap;
-//CHECKSTYLE:OFF
-import java.util.Map; // Suppress warning as the Map import is needed for the JavaDoc Comments
+import java.time.Instant;
import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
-//CHECKSTYLE:ON
-
-
/**
* Unit test for {@link Transloadit} class. Api-Responses are simulated by mocking the server's response.
@@ -281,5 +281,26 @@ public void loadVersionInfo() {
Matcher matcher = versionPattern.matcher(info);
Assertions.assertTrue(matcher.find());
}
+
+ /**
+ * Test if the SDK can generate a correct signed Smart CDN URL.
+ */
+ @Test
+ @SuppressWarnings("checkstyle:linelength")
+ public void getSignedSmartCDNURL() throws LocalOperationException {
+ Transloadit client = new Transloadit("foo_key", "foo_secret");
+ Map> params = new HashMap<>();
+ params.put("foo", Collections.singletonList("bar"));
+ params.put("aaa", Arrays.asList("42", "21")); // Must be sorted before `foo`
+
+ String url = client.getSignedSmartCDNUrl(
+ "foo_workspace",
+ "foo_template",
+ "foo/input",
+ params,
+ Instant.parse("2024-05-01T01:00:00.000Z").toEpochMilli()
+ );
+ Assertions.assertEquals("https://foo_workspace.tlcdn.com/foo_template/foo%2Finput?aaa=42&aaa=21&auth_key=foo_key&exp=1714525200000&foo=bar&sig=sha256%3A9a8df3bb28eea621b46ec808a250b7903b2546be7e66c048956d4f30b8da7519", url);
+ }
}