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