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); + } }