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