Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 5042fdd

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 5042fdd

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)

AltStyle によって変換されたページ (->オリジナル) /