Skip to content

Commit ee765b6

Browse files
structurizr-dsl: Deprecates setRestricted(boolean) in favour of finer-grained features.
1 parent ba4fd46 commit ee765b6

40 files changed

+1073
-495
lines changed

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- structurizr-dsl: PlantUML, Mermaid, and Kroki image views can now be defined by an inline source block.
2121
- structurizr-dsl: Constants and variables are inherited when extending a DSL workspace.
2222
- structurizr-dsl: DSL source is only stored in the JSON workspace when the DSL is deemed as "portable" (i.e. no files, plugins, scripts).
23+
- structurizr-dsl: Deprecates `setRestricted(boolean)` in favour of finer-grained features.
2324
- structurizr-export: Removes support for deprecated enterprise and location concepts.
2425
- structurizr-export: PlantUML exporters - replaces skinparams with styles.
2526
- structurizr-export: PlantUML exporters - adds support for dark mode exports.

structurizr-client/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ dependencies {
22

33
api project(':structurizr-core')
44

5-
api 'com.fasterxml.jackson.core:jackson-databind:2.18.3'
6-
api 'org.apache.httpcomponents.client5:httpclient5:5.4.3'
5+
api 'com.fasterxml.jackson.core:jackson-databind:2.20.0'
6+
api 'org.apache.httpcomponents.client5:httpclient5:5.5.1'
77
api 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359'
88

99
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package com.structurizr.http;
2+
3+
import org.apache.hc.client5.http.classic.methods.HttpGet;
4+
import org.apache.hc.client5.http.config.ConnectionConfig;
5+
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
6+
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
7+
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
8+
import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager;
9+
import org.apache.hc.core5.http.io.entity.EntityUtils;
10+
11+
import java.util.HashMap;
12+
import java.util.HashSet;
13+
import java.util.Map;
14+
import java.util.Set;
15+
import java.util.concurrent.TimeUnit;
16+
17+
/**
18+
* Wrapper for the HTTPClient in Apache HttpComponents, with optional caching and allowed URLs (via regexes).
19+
*/
20+
public class HttpClient {
21+
22+
public static final String CONTENT_TYPE_IMAGE_PNG = "image/png";
23+
24+
private static final int HTTP_OK_STATUS = 200;
25+
26+
private int timeout = 10000; // milliseconds
27+
private final Set<String> allowedUrlRegexes = new HashSet<>();
28+
29+
private final Map<String,RemoteContent> contentCache = new HashMap<>();
30+
31+
public HttpClient() {
32+
}
33+
34+
/**
35+
* Sets the timeout in milliseconds.
36+
*
37+
* @param timeoutInMilliseconds the timeout in milliseconds
38+
*/
39+
public void setTimeout(int timeoutInMilliseconds) {
40+
if (timeoutInMilliseconds < 0) {
41+
throw new IllegalArgumentException("Timeout must be a positive integer");
42+
}
43+
44+
this.timeout = timeoutInMilliseconds;
45+
}
46+
47+
/**
48+
* HTTP GET of a URL, without caching.
49+
*
50+
* @param url the URL, as a String
51+
* @return a RemoteContent object representing the response
52+
*/
53+
public RemoteContent get(String url) {
54+
return get(url, false);
55+
}
56+
57+
/**
58+
* HTTP GET of a URL.
59+
*
60+
* @param url the URL, as a String
61+
* @param cache true if the result should be cached, false otherwise
62+
* @return a RemoteContent object representing the response
63+
*/
64+
public RemoteContent get(String url, boolean cache) {
65+
if (!isAllowed(url)) {
66+
throw new RuntimeException("Access to " + url + " is not permitted");
67+
}
68+
69+
RemoteContent remoteContent = contentCache.get(url);
70+
if (remoteContent == null) {
71+
ConnectionConfig connectionConfig = ConnectionConfig.custom()
72+
.setConnectTimeout(timeout, TimeUnit.MILLISECONDS)
73+
.setSocketTimeout(timeout, TimeUnit.MILLISECONDS)
74+
.build();
75+
76+
BasicHttpClientConnectionManager cm = new BasicHttpClientConnectionManager();
77+
cm.setConnectionConfig(connectionConfig);
78+
79+
try (CloseableHttpClient httpClient = HttpClientBuilder.create()
80+
.useSystemProperties()
81+
.setConnectionManager(cm)
82+
.build()) {
83+
84+
HttpGet httpGet = new HttpGet(url);
85+
CloseableHttpResponse response = httpClient.execute(httpGet);
86+
87+
int httpStatus = response.getCode();
88+
if (httpStatus == HTTP_OK_STATUS) {
89+
String contentType = response.getEntity().getContentType();
90+
if (CONTENT_TYPE_IMAGE_PNG.equals(contentType)) {
91+
remoteContent = new RemoteContent(EntityUtils.toByteArray(response.getEntity()), contentType);
92+
} else {
93+
remoteContent = new RemoteContent(EntityUtils.toString(response.getEntity()), contentType);
94+
}
95+
96+
if (cache) {
97+
contentCache.put(url, remoteContent);
98+
}
99+
} else {
100+
throw new RuntimeException("The content from " + url + " could not be loaded: HTTP status=" + httpStatus);
101+
}
102+
} catch (Exception ioe) {
103+
throw new RuntimeException("The content from " + url + " could not be loaded: " + ioe.getMessage());
104+
}
105+
}
106+
107+
return remoteContent;
108+
}
109+
110+
/**
111+
* Adds an allowed URL regex.
112+
*
113+
* @param regex the regex to allow
114+
*/
115+
public void allow(String regex) {
116+
allowedUrlRegexes.add(regex);
117+
}
118+
119+
private boolean isAllowed(String url) {
120+
for (String regex : allowedUrlRegexes) {
121+
if (url.matches(regex)) {
122+
return true;
123+
}
124+
}
125+
126+
return false;
127+
}
128+
129+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.structurizr.http;
2+
3+
/**
4+
* Wrapper for remote content loaded via HTTP.
5+
*/
6+
public final class RemoteContent {
7+
8+
public static final String CONTENT_TYPE_JSON = "application/json";
9+
10+
private final String content;
11+
private final byte[] bytes;
12+
private final String contentType;
13+
14+
RemoteContent(String content, String contentType) {
15+
this.content = content;
16+
this.bytes = null;
17+
this.contentType = contentType;
18+
}
19+
20+
RemoteContent(byte[] content, String contentType) {
21+
this.content = null;
22+
this.bytes = content;
23+
this.contentType = contentType;
24+
}
25+
26+
public String getContentAsString() {
27+
return content;
28+
}
29+
30+
public byte[] getContentAsBytes() {
31+
return bytes;
32+
}
33+
34+
public String getContentType() {
35+
return contentType;
36+
}
37+
38+
}

structurizr-client/src/main/java/com/structurizr/view/ThemeUtils.java

Lines changed: 29 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,32 +5,22 @@
55
import com.fasterxml.jackson.databind.ObjectMapper;
66
import com.fasterxml.jackson.databind.SerializationFeature;
77
import com.structurizr.Workspace;
8+
import com.structurizr.http.HttpClient;
9+
import com.structurizr.http.RemoteContent;
810
import com.structurizr.io.WorkspaceWriterException;
9-
import com.structurizr.model.Relationship;
1011
import com.structurizr.util.ImageUtils;
1112
import com.structurizr.util.StringUtils;
1213
import com.structurizr.util.Url;
13-
import org.apache.hc.client5.http.classic.methods.HttpGet;
14-
import org.apache.hc.client5.http.config.ConnectionConfig;
15-
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
16-
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
17-
import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
18-
import org.apache.hc.client5.http.impl.classic.HttpClients;
19-
import org.apache.hc.client5.http.impl.io.BasicHttpClientConnectionManager;
20-
import org.apache.hc.core5.http.io.entity.EntityUtils;
2114

2215
import java.io.*;
2316
import java.nio.charset.StandardCharsets;
2417
import java.nio.file.Files;
25-
import java.util.concurrent.TimeUnit;
2618

2719
/**
2820
* Some utility methods for exporting themes to JSON.
2921
*/
3022
public final class ThemeUtils {
3123

32-
private static final int HTTP_OK_STATUS = 200;
33-
3424
private static final int DEFAULT_TIMEOUT_IN_MILLISECONDS = 10000;
3525

3626
/**
@@ -90,27 +80,38 @@ public static void loadThemes(Workspace workspace) throws Exception {
9080
* @throws Exception if something goes wrong
9181
*/
9282
public static void loadThemes(Workspace workspace, int timeoutInMilliseconds) throws Exception {
83+
HttpClient httpClient = new HttpClient();
84+
httpClient.setTimeout(timeoutInMilliseconds);
85+
86+
loadThemes(workspace, httpClient);
87+
}
88+
89+
public static void loadThemes(Workspace workspace, HttpClient httpClient) throws Exception {
9390
for (String themeLocation : workspace.getViews().getConfiguration().getThemes()) {
9491
if (Url.isUrl(themeLocation)) {
95-
String json = loadFrom(themeLocation, timeoutInMilliseconds);
96-
Theme theme = fromJson(json);
97-
String baseUrl = themeLocation.substring(0, themeLocation.lastIndexOf('/') + 1);
98-
99-
for (ElementStyle elementStyle : theme.getElements()) {
100-
String icon = elementStyle.getIcon();
101-
if (!StringUtils.isNullOrEmpty(icon)) {
102-
if (icon.startsWith("http")) {
103-
// okay, image served over HTTP
104-
} else if (icon.startsWith("data:image")) {
105-
// also okay, data URI
106-
} else {
107-
// convert the relative icon filename into a full URL
108-
elementStyle.setIcon(baseUrl + icon);
92+
RemoteContent remoteContent = httpClient.get(themeLocation);
93+
if (remoteContent.getContentType().equals(RemoteContent.CONTENT_TYPE_JSON)) {
94+
Theme theme = fromJson(remoteContent.getContentAsString());
95+
String baseUrl = themeLocation.substring(0, themeLocation.lastIndexOf('/') + 1);
96+
97+
for (ElementStyle elementStyle : theme.getElements()) {
98+
String icon = elementStyle.getIcon();
99+
if (!StringUtils.isNullOrEmpty(icon)) {
100+
if (Url.isHttpUrl(icon) || Url.isHttpsUrl(icon)) {
101+
// okay, image served over HTTP or HTTPS
102+
} else if (icon.startsWith("data:image")) {
103+
// also okay, data URI
104+
} else {
105+
// convert the relative icon filename into a full URL
106+
elementStyle.setIcon(baseUrl + icon);
107+
}
109108
}
110109
}
111-
}
112110

113-
workspace.getViews().getConfiguration().getStyles().addStylesFromTheme(theme);
111+
workspace.getViews().getConfiguration().getStyles().addStylesFromTheme(theme);
112+
} else {
113+
throw new RuntimeException(String.format("%s - expected content type of %s, actual content type is %s", themeLocation, RemoteContent.CONTENT_TYPE_JSON, remoteContent.getContentType()));
114+
}
114115
}
115116
}
116117
}
@@ -144,31 +145,6 @@ public static void inlineTheme(Workspace workspace, File file) throws Exception
144145
workspace.getViews().getConfiguration().getStyles().inlineTheme(theme);
145146
}
146147

147-
private static String loadFrom(String url, int timeoutInMilliseconds) throws Exception {
148-
ConnectionConfig connectionConfig = ConnectionConfig.custom()
149-
.setConnectTimeout(timeoutInMilliseconds, TimeUnit.MILLISECONDS)
150-
.setSocketTimeout(timeoutInMilliseconds, TimeUnit.MILLISECONDS)
151-
.build();
152-
153-
BasicHttpClientConnectionManager cm = new BasicHttpClientConnectionManager();
154-
cm.setConnectionConfig(connectionConfig);
155-
156-
try (CloseableHttpClient httpClient = HttpClientBuilder.create()
157-
.useSystemProperties()
158-
.setConnectionManager(cm)
159-
.build()) {
160-
161-
HttpGet httpGet = new HttpGet(url);
162-
163-
CloseableHttpResponse response = httpClient.execute(httpGet);
164-
if (response.getCode() == HTTP_OK_STATUS) {
165-
return EntityUtils.toString(response.getEntity());
166-
}
167-
}
168-
169-
return "";
170-
}
171-
172148
private static Theme fromJson(String json) throws Exception {
173149
ObjectMapper objectMapper = new ObjectMapper();
174150
objectMapper.enable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.structurizr.http;
2+
3+
import org.junit.jupiter.api.Tag;
4+
import org.junit.jupiter.api.Test;
5+
6+
import static org.junit.jupiter.api.Assertions.assertEquals;
7+
8+
public class HttpClientTests {
9+
10+
@Test
11+
@Tag("IntegrationTest")
12+
void get_WhenNoAllowedUrlsAreConfigured() {
13+
HttpClient httpClient = new HttpClient();
14+
15+
try {
16+
httpClient.get("https://static.structurizr.com/themes/microsoft-azure-2024.07.15/icons.json");
17+
} catch (Exception e) {
18+
assertEquals("Access to https://static.structurizr.com/themes/microsoft-azure-2024.07.15/icons.json is not permitted", e.getMessage());
19+
}
20+
}
21+
22+
@Test
23+
@Tag("IntegrationTest")
24+
void get_WithAllowedUrl() {
25+
HttpClient httpClient = new HttpClient();
26+
httpClient.allow("https://static.structurizr.com/themes/amazon-web-services.*");
27+
28+
httpClient.get("https://static.structurizr.com/themes/amazon-web-services-2023.01.31/icons.json");
29+
30+
try {
31+
httpClient.get("https://static.structurizr.com/themes/microsoft-azure-2024.07.15/icons.json");
32+
} catch (Exception e) {
33+
assertEquals("Access to https://static.structurizr.com/themes/microsoft-azure-2024.07.15/icons.json is not permitted", e.getMessage());
34+
}
35+
}
36+
37+
@Test
38+
@Tag("IntegrationTest")
39+
void get_WithDisallowedUrl() {
40+
HttpClient httpClient = new HttpClient();
41+
httpClient.allow("https://static.structurizr.com/.*");
42+
43+
try {
44+
httpClient.get("https://example.com");
45+
} catch (Exception e) {
46+
assertEquals("Access to https://example.com is not permitted", e.getMessage());
47+
}
48+
}
49+
50+
}

structurizr-core/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
dependencies {
22

3-
api 'com.fasterxml.jackson.core:jackson-annotations:2.18.3'
3+
api 'com.fasterxml.jackson.core:jackson-annotations:2.20'
44
api 'com.google.code.findbugs:jsr305:3.0.2'
55
api 'commons-logging:commons-logging:1.3.5'
66

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.structurizr.util;
2+
3+
public final class FeatureNotEnabledException extends RuntimeException {
4+
5+
public FeatureNotEnabledException(String feature) {
6+
super("Feature " + feature + " is not enabled");
7+
}
8+
9+
public FeatureNotEnabledException(String feature, String message) {
10+
super(String.format("%s (feature %s is not enabled)", message, feature));
11+
}
12+
13+
}

0 commit comments

Comments
 (0)