Skip to content

Commit e9232cb

Browse files
author
Elier Herrera
committed
feat(pwa-server): per-app icon endpoints, manifest security scope, and local-dev config; refactor ApplicationController; tests updated
1 parent 509405d commit e9232cb

File tree

8 files changed

+387
-8
lines changed

8 files changed

+387
-8
lines changed

server/api-service/lowcoder-server/pom.xml

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,6 @@
7777
<groupId>org.springdoc</groupId>
7878
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
7979
</dependency>
80-
<dependency>
81-
<groupId>org.springdoc</groupId>
82-
<artifactId>springdoc-openapi-webflux-ui</artifactId>
83-
<version>1.8.0</version>
84-
</dependency>
8580
<dependency>
8681
<groupId>io.projectreactor.tools</groupId>
8782
<artifactId>blockhound</artifactId>

server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/OpenAPIDocsConfiguration.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import io.swagger.v3.oas.models.servers.ServerVariables;
1313
import io.swagger.v3.oas.models.tags.Tag;
1414
import org.lowcoder.sdk.config.CommonConfig;
15-
import org.springdoc.core.customizers.OpenApiCustomiser;
15+
import org.springdoc.core.customizers.OpenApiCustomizer;
1616
import org.springframework.beans.factory.annotation.Autowired;
1717
import org.springframework.beans.factory.annotation.Value;
1818
import org.springframework.context.annotation.Bean;
@@ -135,7 +135,7 @@ private Server createCustomServer() {
135135
* Customizes the OpenAPI spec at runtime to sort tags and paths.
136136
*/
137137
@Bean
138-
public OpenApiCustomiser sortOpenApiSpec() {
138+
public OpenApiCustomizer sortOpenApiSpec() {
139139
return openApi -> {
140140
// Sort tags alphabetically
141141
if (openApi.getTags() != null) {
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package org.lowcoder.api.application;
2+
3+
import jakarta.annotation.Nullable;
4+
import lombok.RequiredArgsConstructor;
5+
import lombok.extern.slf4j.Slf4j;
6+
import org.lowcoder.api.framework.view.ResponseView;
7+
import org.lowcoder.domain.application.model.Application;
8+
import org.lowcoder.domain.application.service.ApplicationRecordService;
9+
import org.lowcoder.domain.application.service.ApplicationService;
10+
import org.lowcoder.infra.constant.NewUrl;
11+
import org.lowcoder.infra.constant.Url;
12+
import org.springframework.http.CacheControl;
13+
import org.springframework.http.HttpHeaders;
14+
import org.springframework.http.MediaType;
15+
import org.springframework.http.server.reactive.ServerHttpResponse;
16+
import org.springframework.web.bind.annotation.GetMapping;
17+
import org.springframework.web.bind.annotation.PathVariable;
18+
import org.springframework.web.bind.annotation.RequestMapping;
19+
import org.springframework.web.bind.annotation.RequestParam;
20+
import org.springframework.web.bind.annotation.RestController;
21+
import reactor.core.publisher.Mono;
22+
23+
import javax.imageio.ImageIO;
24+
import java.awt.Color;
25+
import java.awt.Font;
26+
import java.awt.FontMetrics;
27+
import java.awt.Graphics2D;
28+
import java.awt.RenderingHints;
29+
import java.awt.image.BufferedImage;
30+
import java.io.ByteArrayInputStream;
31+
import java.io.ByteArrayOutputStream;
32+
import java.io.InputStream;
33+
import java.net.URL;
34+
import java.time.Duration;
35+
import java.util.*;
36+
import java.util.concurrent.ConcurrentHashMap;
37+
38+
/**
39+
* Serves per-application icons and PWA manifest.
40+
*/
41+
@RequiredArgsConstructor
42+
@RestController
43+
@RequestMapping({Url.APPLICATION_URL, NewUrl.APPLICATION_URL})
44+
@Slf4j
45+
public class AppIconController {
46+
47+
private static final List<Integer> ALLOWED_SIZES = List.of(48, 72, 96, 120, 128, 144, 152, 167, 180, 192, 256, 384, 512);
48+
49+
private final ApplicationService applicationService;
50+
private final ApplicationRecordService applicationRecordService;
51+
52+
private static final long CACHE_TTL_MILLIS = Duration.ofHours(12).toMillis();
53+
private static final int CACHE_MAX_ENTRIES = 2000;
54+
private static final Map<String, CacheEntry> ICON_CACHE = new ConcurrentHashMap<>();
55+
56+
private record CacheEntry(byte[] data, long expiresAtMs) {}
57+
58+
private static String buildCacheKey(String applicationId, String iconIdentifier, String appName, int size, @Nullable Color bgColor) {
59+
String id = (iconIdentifier == null || iconIdentifier.isBlank()) ? ("placeholder:" + Objects.toString(appName, "Lowcoder")) : iconIdentifier;
60+
String bg = (bgColor == null) ? "none" : (bgColor.getRed()+","+bgColor.getGreen()+","+bgColor.getBlue());
61+
return applicationId + "|" + id + "|" + size + "|" + bg;
62+
}
63+
64+
@GetMapping("/{applicationId}/icons")
65+
public Mono<ResponseView<Map<String, Object>>> getAvailableIconSizes(@PathVariable String applicationId) {
66+
Map<String, Object> payload = new HashMap<>();
67+
payload.put("sizes", ALLOWED_SIZES);
68+
return Mono.just(ResponseView.success(payload));
69+
}
70+
71+
@GetMapping("/{applicationId}/icons/{size}.png")
72+
public Mono<Void> getIconPng(@PathVariable String applicationId,
73+
@PathVariable int size,
74+
@RequestParam(name = "bg", required = false) String bg,
75+
ServerHttpResponse response) {
76+
if (!ALLOWED_SIZES.contains(size)) {
77+
// clamp to a safe default
78+
int fallback = 192;
79+
return getIconPng(applicationId, fallback, bg, response);
80+
}
81+
82+
response.getHeaders().setContentType(MediaType.IMAGE_PNG);
83+
response.getHeaders().setCacheControl(CacheControl.maxAge(Duration.ofDays(7)).cachePublic());
84+
85+
final Color bgColor = parseColor(bg);
86+
87+
return applicationService.findById(applicationId)
88+
.flatMap(app -> Mono.zip(Mono.just(app), app.getIcon(applicationRecordService)))
89+
.flatMap(tuple -> {
90+
Application app = tuple.getT1();
91+
String iconIdentifier = Optional.ofNullable(tuple.getT2()).orElse("");
92+
String cacheKey = buildCacheKey(applicationId, iconIdentifier, app.getName(), size, bgColor);
93+
94+
// Cache hit
95+
CacheEntry cached = ICON_CACHE.get(cacheKey);
96+
if (cached != null && cached.expiresAtMs() > System.currentTimeMillis()) {
97+
byte[] bytes = cached.data();
98+
return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes))).then();
99+
}
100+
101+
// Cache miss: render and store
102+
return Mono.fromCallable(() -> buildIconPng(iconIdentifier, app.getName(), size, bgColor))
103+
.onErrorResume(e -> {
104+
log.warn("Failed to generate icon for app {}: {}", applicationId, e.getMessage());
105+
return Mono.fromCallable(() -> buildPlaceholderPng(app.getName(), size, bgColor));
106+
})
107+
.flatMap(bytes -> {
108+
putInCache(cacheKey, bytes);
109+
return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes))).then();
110+
});
111+
})
112+
.switchIfEmpty(Mono.defer(() -> {
113+
String cacheKey = buildCacheKey(applicationId, "", "Lowcoder", size, bgColor);
114+
CacheEntry cached = ICON_CACHE.get(cacheKey);
115+
if (cached != null && cached.expiresAtMs() > System.currentTimeMillis()) {
116+
byte[] bytes = cached.data();
117+
return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes))).then();
118+
}
119+
byte[] bytes = buildPlaceholderPng("Lowcoder", size, bgColor);
120+
putInCache(cacheKey, bytes);
121+
return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes))).then();
122+
}));
123+
}
124+
125+
private static void putInCache(String key, byte[] data) {
126+
long expires = System.currentTimeMillis() + CACHE_TTL_MILLIS;
127+
if (ICON_CACHE.size() >= CACHE_MAX_ENTRIES) {
128+
// Best-effort cleanup of expired entries; if still large, remove one arbitrary entry
129+
ICON_CACHE.entrySet().removeIf(e -> e.getValue().expiresAtMs() <= System.currentTimeMillis());
130+
if (ICON_CACHE.size() >= CACHE_MAX_ENTRIES) {
131+
String firstKey = ICON_CACHE.keySet().stream().findFirst().orElse(null);
132+
if (firstKey != null) ICON_CACHE.remove(firstKey);
133+
}
134+
}
135+
ICON_CACHE.put(key, new CacheEntry(data, expires));
136+
}
137+
138+
private static byte[] buildIconPng(String iconIdentifier, String appName, int size, @Nullable Color bgColor) throws Exception {
139+
BufferedImage source = tryLoadImage(iconIdentifier);
140+
if (source == null) {
141+
return buildPlaceholderPng(appName, size, bgColor);
142+
}
143+
return scaleToSquarePng(source, size, bgColor);
144+
}
145+
146+
private static BufferedImage tryLoadImage(String iconIdentifier) {
147+
if (iconIdentifier == null || iconIdentifier.isBlank()) return null;
148+
try {
149+
if (iconIdentifier.startsWith("data:image")) {
150+
String base64 = iconIdentifier.substring(iconIdentifier.indexOf(",") + 1);
151+
byte[] data = Base64.getDecoder().decode(base64);
152+
try (InputStream in = new ByteArrayInputStream(data)) {
153+
return ImageIO.read(in);
154+
}
155+
}
156+
if (iconIdentifier.startsWith("http://") || iconIdentifier.startsWith("https://")) {
157+
try (InputStream in = new URL(iconIdentifier).openStream()) {
158+
return ImageIO.read(in);
159+
}
160+
}
161+
} catch (Exception e) {
162+
// ignore and fallback
163+
}
164+
return null;
165+
}
166+
167+
private static byte[] scaleToSquarePng(BufferedImage source, int size, @Nullable Color bgColor) throws Exception {
168+
int w = source.getWidth();
169+
int h = source.getHeight();
170+
double scale = Math.min((double) size / w, (double) size / h);
171+
int newW = Math.max(1, (int) Math.round(w * scale));
172+
int newH = Math.max(1, (int) Math.round(h * scale));
173+
174+
BufferedImage canvas = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
175+
Graphics2D g = canvas.createGraphics();
176+
try {
177+
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
178+
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
179+
if (bgColor != null) {
180+
g.setColor(bgColor);
181+
g.fillRect(0, 0, size, size);
182+
}
183+
int x = (size - newW) / 2;
184+
int y = (size - newH) / 2;
185+
g.drawImage(source, x, y, newW, newH, null);
186+
} finally {
187+
g.dispose();
188+
}
189+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
190+
ImageIO.write(canvas, "png", baos);
191+
return baos.toByteArray();
192+
}
193+
194+
private static byte[] buildPlaceholderPng(String appName, int size, @Nullable Color bgColor) {
195+
try {
196+
BufferedImage canvas = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
197+
Graphics2D g = canvas.createGraphics();
198+
try {
199+
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
200+
Color background = bgColor != null ? bgColor : new Color(0xB4, 0x80, 0xDE); // #b480de
201+
g.setColor(background);
202+
g.fillRect(0, 0, size, size);
203+
// draw first letter as simple placeholder
204+
String letter = (appName != null && !appName.isBlank()) ? appName.substring(0, 1).toUpperCase() : "L";
205+
g.setColor(Color.WHITE);
206+
int fontSize = Math.max(24, (int) (size * 0.5));
207+
g.setFont(new Font("SansSerif", Font.BOLD, fontSize));
208+
FontMetrics fm = g.getFontMetrics();
209+
int textW = fm.stringWidth(letter);
210+
int textH = fm.getAscent();
211+
int x = (size - textW) / 2;
212+
int y = (size + textH / 2) / 2;
213+
g.drawString(letter, x, y);
214+
} finally {
215+
g.dispose();
216+
}
217+
ByteArrayOutputStream baos = new ByteArrayOutputStream();
218+
ImageIO.write(canvas, "png", baos);
219+
return baos.toByteArray();
220+
} catch (Exception e) {
221+
// last resort
222+
return new byte[0];
223+
}
224+
}
225+
226+
@Nullable
227+
private static Color parseColor(@Nullable String hex) {
228+
if (hex == null || hex.isBlank()) return null;
229+
String v = hex.trim();
230+
if (v.startsWith("#")) v = v.substring(1);
231+
try {
232+
if (v.length() == 6) {
233+
int r = Integer.parseInt(v.substring(0, 2), 16);
234+
int g = Integer.parseInt(v.substring(2, 4), 16);
235+
int b = Integer.parseInt(v.substring(4, 6), 16);
236+
return new Color(r, g, b);
237+
}
238+
} catch (Exception ignored) {
239+
}
240+
return null;
241+
}
242+
243+
244+
}

0 commit comments

Comments
 (0)