Commit: 690c1f6

Commit Details

SHA690c1f6ce426c62c29e87ac132dda0f8125192ff
Tree5a67fd55b56735ab4e469c7d06c1c73c4c2b4c20
Author<f69e50@finnacloud.com> 1766368110 +0300
Committer<f69e50@finnacloud.com> 1766368110 +0300
Message
initialize backend structure with controllers, DTOs, and configuration files
GPG Signature
-----BEGIN PGP SIGNATURE-----

iQJSBAABCAA8FiEEWJb139mJI+vZ81KkoAIVSUsXI0oFAmlIo24eHHNvcGhpYS5l
cmFzbGFuQGZpbm5hY2xvdWQuY29tAAoJEKACFUlLFyNKwXYP/RvWx8mxXoZbKEVA
wQFC9UnzcoL/lElB5QMr9opKzRv4uGgFkKMDhbSnqE6NoET5H5VanOFQ9u5a4Khi
9PBTLIEBjbEqA1trC+aTDk3EplVtQYYbSn19CdMSCW7FXJNSg0IiyWKA44iH8Ts0
Xcxh59m6WcwvRDhxQDy6hCXqUa9ISNNk75KRnJS/qRGIEy94DwUYxVfCJpAfzyVu
VmdinE6kZM2GDj8MBTPQTzi6hMf/e9CcAg51tf4oNtd9tnW8QKpTsPsFy434VUDh
Vxtv/oAJ5tuTIprs2BSnyZ6Kb8RDwDdmQyuKjZMUIwZTH/TCE85SZFf5sa5tpYOH
2KekT52ffZgKw/DUtpNosW6qeHgCJlSvwY7BW7M90X5xJuahrKS04lEDBO04cIRn
bg0ayPFTp7Idhl1OuTRhWS6e344g44mJ/9sZK1sXd/0U8OKBbytk35AnCIPCEdSX
8vgjqBR9Wt3A/Kel5j2VcUFDhrAR72a9lJiQHNBicvcVu9Nd41vDnEwUDdNQv6Uc
6omEz3pkVkq+89/eW1KQM8LvrIuGQ/wIUgykvCNSCQ5oba2fjtAXzI+SmxpeCWOz
jTKZOEJyhIQE7uvaUj6/0D2JwlxbMG27fcUN7N3aKv6mSVP7hYnHaUbRLQ+f8/xU
VfU776FkUXS+4CfSooHtu+ioul9O
=ZuqE
-----END PGP SIGNATURE-----

✓ Verified

File: src/main/java/com/paymentlink/service/FlagService.java

1 package com.paymentlink.service;
2
3 import com.github.benmanes.caffeine.cache.Cache;
4 import com.github.benmanes.caffeine.cache.Caffeine;
5 import org.slf4j.Logger;
6 import org.slf4j.LoggerFactory;
7 import org.springframework.beans.factory.annotation.Value;
8 import org.springframework.http.HttpMethod;
9 import org.springframework.http.ResponseEntity;
10 import org.springframework.stereotype.Service;
11 import org.springframework.web.client.RestTemplate;
12
13 import java.io.IOException;
14 import java.nio.file.Files;
15 import java.nio.file.Path;
16 import java.nio.file.Paths;
17 import java.time.Duration;
18 import java.util.Base64;
19 import java.util.Set;
20 import java.util.concurrent.TimeUnit;
21
22 /**
23 * Service for fetching and caching country flags
24 * Fetches flags from external API and caches them locally
25 */
26 @Service
27 public class FlagService {
28
29 private static final Logger logger = LoggerFactory.getLogger(FlagService.class);
30
31 // Blocklist of territories we do not recognize
32 private static final Set<String> BLOCKED_TERRITORIES = Set.of(
33 "EH", // Western Sahara
34 "PS", // Palestine
35 "TW" // Taiwan (Republic of China)
36 );
37
38 private final RestTemplate restTemplate;
39 private final String flagApiUrl;
40 private final Path flagCacheDir;
41
42 // In-memory cache for base64 encoded flags
43 private final Cache<String, String> flagCache;
44
45 public FlagService(
46 RestTemplate restTemplate,
47 @Value("${flags.api.url:https://flagcdn.com/w40}") String flagApiUrl,
48 @Value("${flags.cache.directory:flags}") String flagCacheDirectory) {
49
50 this.restTemplate = restTemplate;
51 this.flagApiUrl = flagApiUrl;
52 this.flagCacheDir = Paths.get(flagCacheDirectory);
53
54 // Create cache directory if it doesn't exist
55 try {
56 Files.createDirectories(flagCacheDir);
57 } catch (IOException e) {
58 logger.error("Failed to create flag cache directory", e);
59 }
60
61 // Initialize in-memory cache (30 days TTL)
62 this.flagCache = Caffeine.newBuilder()
63 .expireAfterWrite(30, TimeUnit.DAYS)
64 .maximumSize(500)
65 .build();
66
67 logger.info("FlagService initialized with cache directory: {}", flagCacheDir.toAbsolutePath());
68 }
69
70 /**
71 * Get flag for a country code as base64 data URL
72 *
73 * @param countryCode ISO 3166-1 alpha-2 country code (e.g., "US", "TR")
74 * @return Base64 data URL or null if not found or blocked
75 */
76 public String getFlag(String countryCode) {
77 if (countryCode == null || countryCode.length() != 2) {
78 return null;
79 }
80
81 String code = countryCode.toLowerCase();
82 String upperCode = countryCode.toUpperCase();
83
84 // Check if territory is blocked
85 if (BLOCKED_TERRITORIES.contains(upperCode)) {
86 logger.debug("Flag request blocked for non-recognized territory: {}", upperCode);
87 return null;
88 }
89
90 // Check in-memory cache first
91 String cachedFlag = flagCache.getIfPresent(code);
92 if (cachedFlag != null) {
93 return cachedFlag;
94 }
95
96 // Check disk cache
97 String diskFlag = loadFromDisk(code);
98 if (diskFlag != null) {
99 flagCache.put(code, diskFlag);
100 return diskFlag;
101 }
102
103 // Fetch from external API
104 String fetchedFlag = fetchFlagFromApi(code);
105 if (fetchedFlag != null) {
106 flagCache.put(code, fetchedFlag);
107 saveToDisk(code, fetchedFlag);
108 return fetchedFlag;
109 }
110
111 return null;
112 }
113
114 /**
115 * Fetch flag from external API
116 */
117 private String fetchFlagFromApi(String countryCode) {
118 try {
119 String url = String.format("%s/%s.png", flagApiUrl, countryCode);
120
121 logger.debug("Fetching flag for {} from {}", countryCode.toUpperCase(), url);
122
123 ResponseEntity<byte[]> response = restTemplate.exchange(
124 url,
125 HttpMethod.GET,
126 null,
127 byte[].class
128 );
129
130 if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
131 byte[] imageBytes = response.getBody();
132 String base64 = Base64.getEncoder().encodeToString(imageBytes);
133 String dataUrl = "data:image/png;base64," + base64;
134
135 logger.info("Successfully fetched flag for {}", countryCode.toUpperCase());
136 return dataUrl;
137 }
138
139 } catch (Exception e) {
140 logger.warn("Failed to fetch flag for {} from external API: {}",
141 countryCode.toUpperCase(), e.getMessage());
142 }
143
144 return null;
145 }
146
147 /**
148 * Load flag from disk cache
149 */
150 private String loadFromDisk(String countryCode) {
151 try {
152 Path flagFile = flagCacheDir.resolve(countryCode + ".txt");
153 if (Files.exists(flagFile)) {
154 String dataUrl = Files.readString(flagFile);
155 logger.debug("Loaded flag for {} from disk cache", countryCode.toUpperCase());
156 return dataUrl;
157 }
158 } catch (IOException e) {
159 logger.warn("Failed to load flag for {} from disk: {}",
160 countryCode.toUpperCase(), e.getMessage());
161 }
162 return null;
163 }
164
165 /**
166 * Save flag to disk cache
167 */
168 private void saveToDisk(String countryCode, String dataUrl) {
169 try {
170 Path flagFile = flagCacheDir.resolve(countryCode + ".txt");
171 Files.writeString(flagFile, dataUrl);
172 logger.debug("Saved flag for {} to disk cache", countryCode.toUpperCase());
173 } catch (IOException e) {
174 logger.warn("Failed to save flag for {} to disk: {}",
175 countryCode.toUpperCase(), e.getMessage());
176 }
177 }
178
179 /**
180 * Get SVG fallback icon for countries without flags
181 */
182 public String getFallbackIcon() {
183 return """
184 <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
185 <circle cx="12" cy="12" r="10"></circle>
186 <line x1="2" y1="12" x2="22" y2="12"></line>
187 <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
188 </svg>
189 """;
190 }
191
192 /**
193 * Check if a territory is blocked (not recognized)
194 *
195 * @param countryCode ISO 3166-1 alpha-2 country code
196 * @return true if blocked, false otherwise
197 */
198 public boolean isBlocked(String countryCode) {
199 if (countryCode == null || countryCode.length() != 2) {
200 return false;
201 }
202 return BLOCKED_TERRITORIES.contains(countryCode.toUpperCase());
203 }
204
205 /**
206 * Get list of blocked territories
207 *
208 * @return Set of blocked country codes
209 */
210 public Set<String> getBlockedTerritories() {
211 return Set.copyOf(BLOCKED_TERRITORIES);
212 }
213
214 /**
215 * Prefetch flags for all given country codes
216 * Useful for warming up the cache on application startup
217 */
218 public void prefetchFlags(String... countryCodes) {
219 for (String code : countryCodes) {
220 if (code != null && code.length() == 2 && !isBlocked(code)) {
221 // This will fetch and cache if not already cached
222 getFlag(code);
223 }
224 }
225 }
226
227 /**
228 * Clear all cached flags (memory and disk)
229 */
230 public void clearCache() {
231 flagCache.invalidateAll();
232
233 try {
234 Files.list(flagCacheDir)
235 .filter(path -> path.toString().endsWith(".txt"))
236 .forEach(path -> {
237 try {
238 Files.delete(path);
239 } catch (IOException e) {
240 logger.warn("Failed to delete cached flag: {}", path);
241 }
242 });
243 logger.info("Flag cache cleared");
244 } catch (IOException e) {
245 logger.error("Failed to clear flag cache", e);
246 }
247 }
248
249 /**
250 * Get cache statistics
251 */
252 public CacheStats getCacheStats() {
253 return new CacheStats(
254 flagCache.estimatedSize(),
255 flagCache.stats().hitCount(),
256 flagCache.stats().missCount(),
257 countDiskCacheFiles()
258 );
259 }
260
261 private long countDiskCacheFiles() {
262 try {
263 return Files.list(flagCacheDir)
264 .filter(path -> path.toString().endsWith(".txt"))
265 .count();
266 } catch (IOException e) {
267 return 0;
268 }
269 }
270
271 public record CacheStats(
272 long memorySize,
273 long hitCount,
274 long missCount,
275 long diskFiles
276 ) {}
277 }
278