001/* 002 * Shredzone Commons 003 * 004 * Copyright (C) 2012 Richard "Shred" Körber 005 * http://commons.shredzone.org 006 * 007 * This program is free software: you can redistribute it and/or modify 008 * it under the terms of the GNU Library General Public License as 009 * published by the Free Software Foundation, either version 3 of the 010 * License, or (at your option) any later version. 011 * 012 * This program is distributed in the hope that it will be useful, 013 * but WITHOUT ANY WARRANTY; without even the implied warranty of 014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 015 * GNU General Public License for more details. 016 * 017 * You should have received a copy of the GNU Library General Public License 018 * along with this program. If not, see <http://www.gnu.org/licenses/>. 019 */ 020 021package org.shredzone.commons.gravatar.impl; 022 023import static java.util.Comparator.comparing; 024import static java.util.stream.Collectors.joining; 025 026import java.io.File; 027import java.io.FileOutputStream; 028import java.io.IOException; 029import java.io.InputStream; 030import java.io.OutputStream; 031import java.io.UnsupportedEncodingException; 032import java.net.URL; 033import java.net.URLConnection; 034import java.security.MessageDigest; 035import java.security.NoSuchAlgorithmException; 036import java.util.Arrays; 037import java.util.stream.IntStream; 038 039import org.shredzone.commons.gravatar.GravatarService; 040import org.slf4j.Logger; 041import org.slf4j.LoggerFactory; 042import org.springframework.beans.factory.annotation.Value; 043import org.springframework.scheduling.annotation.Scheduled; 044import org.springframework.stereotype.Component; 045 046/** 047 * Default implementation of {@link GravatarService}. 048 * 049 * @author Richard "Shred" Körber 050 */ 051@Component("gravatarService") 052public class GravatarServiceImpl implements GravatarService { 053 private static final Logger LOG = LoggerFactory.getLogger(GravatarServiceImpl.class); 054 055 private static final int MAX_GRAVATAR_SIZE = 256 * 1024; // 256 KiB per image 056 private static final int MAX_CACHE_ENTRIES = 500; // 500 entries 057 private static final int MAX_REQUESTS_COUNT = 1000; // 1000 requests 058 private static final long MAX_REQUESTS_RECOVERY = 60 * 1000L; // per minute 059 private static final int TIMEOUT = 10000; // 10 seconds 060 private static final long CACHE_CLEANUP = 60 * 60 * 1000L; // every hour 061 062 @Value("${gravatar.cache.path}") 063 private String cachePath; 064 065 @Value("${gravatar.cache.alive}") 066 private int aliveSeconds; 067 068 @Value("${gravatar.cache.url}") 069 private String gravatarUrl; 070 071 private int requestCounter = 0; 072 private long lastRequest = System.currentTimeMillis(); 073 074 @Override 075 public String computeHash(String mail) { 076 try { 077 MessageDigest md5 = MessageDigest.getInstance("MD5"); 078 md5.reset(); 079 md5.update(mail.trim().toLowerCase().getBytes("UTF-8")); 080 081 byte[] digest = md5.digest(); 082 083 return IntStream.range(0, digest.length) 084 .mapToObj(ix -> String.format("%02x", digest[ix] & 0xFF)) 085 .collect(joining()); 086 } catch (NoSuchAlgorithmException | UnsupportedEncodingException ex) { 087 // should never happen since we use standard stuff 088 throw new InternalError(ex); 089 } 090 } 091 092 @Override 093 public File fetchGravatar(String hash) throws IOException { 094 synchronized (this) { 095 File file = new File(cachePath, hash); 096 097 if (file.exists() && file.isFile() && !isExpired(file)) { 098 return file; 099 } 100 101 URL url = new URL(gravatarUrl.replace("{}", hash)); 102 fetchGravatar(url, file); 103 104 return file; 105 } 106 } 107 108 /** 109 * Checks if the file cache lifetime is expired. 110 * 111 * @param file 112 * {@link File} to check 113 * @return {@code true} if the file is older than cache lifetime 114 */ 115 private boolean isExpired(File file) { 116 long fileTs = file.lastModified(); 117 long expiryTs = System.currentTimeMillis() - (aliveSeconds * 1000L); 118 119 return fileTs < expiryTs; 120 } 121 122 /** 123 * Limits the number of requests to the upstream server. After the limit was reached, 124 * no further requests are permitted for the given recovery time. This way, attacks 125 * to the cache servlet have no impact on the upstream server. 126 * 127 * @throws IOException 128 * when the limit was reached 129 */ 130 private void limitUpstreamRequests() throws IOException { 131 long recoveryTs = System.currentTimeMillis() - MAX_REQUESTS_RECOVERY; 132 if (lastRequest < recoveryTs) { 133 requestCounter = 0; 134 } 135 136 requestCounter++; 137 138 if (requestCounter > MAX_REQUESTS_COUNT && lastRequest >= recoveryTs) { 139 LOG.warn("More than {} requests were made to Gravatar server, recovering!", MAX_REQUESTS_COUNT); 140 throw new IOException("Request limit reached"); 141 } 142 143 lastRequest = System.currentTimeMillis(); 144 } 145 146 /** 147 * Cleans up the gravatar cache. Oldest entries are deleted until cache size is 148 * valid again. 149 */ 150 @Scheduled(fixedDelay = CACHE_CLEANUP) 151 public void cacheCleanup() { 152 Arrays.stream(new File(cachePath).listFiles()) 153 .filter(file -> file.isFile() && !file.isHidden()) 154 .sorted(comparing(File::lastModified).reversed()) // younger files first 155 .skip(MAX_CACHE_ENTRIES) 156 .forEach(GravatarServiceImpl::delete); 157 } 158 159 /** 160 * Deletes a file, logs a warning if it could not be deleted. 161 */ 162 private static void delete(File file) { //NOSONAR: UnusedPrivateMethod, false positive 163 if (!file.delete()) { 164 LOG.warn("Could not delete expired Gravatar cache object: {}", file.getPath()); 165 } 166 } 167 168 /** 169 * Fetches a Gravatar icon from the server and stores it in the given {@link File}. 170 * 171 * @param url 172 * Gravatar URL to fetch 173 * @param file 174 * {@link File} to store the icon to 175 */ 176 private void fetchGravatar(URL url, File file) throws IOException { 177 limitUpstreamRequests(); 178 179 URLConnection conn = url.openConnection(); 180 conn.setConnectTimeout(TIMEOUT); 181 conn.setReadTimeout(TIMEOUT); 182 183 if (file.exists()) { 184 conn.setIfModifiedSince(file.lastModified()); 185 } 186 187 conn.connect(); 188 189 long lastModified = conn.getLastModified(); 190 if (lastModified > 0L && lastModified <= file.lastModified()) { 191 // Cache file exists and is unchanged 192 if (LOG.isDebugEnabled()) { 193 LOG.debug("Cached Gravatar is still good: {}", url); 194 } 195 196 if (!file.setLastModified(System.currentTimeMillis())) { // touch 197 LOG.warn("Failed to touch cache file: {}", file.getAbsolutePath()); 198 } 199 return; 200 } 201 202 try (InputStream in = conn.getInputStream(); 203 OutputStream out = new FileOutputStream(file)) { 204 byte[] buffer = new byte[8192]; 205 int total = 0; 206 int len; 207 208 while ((len = in.read(buffer)) >= 0) { 209 out.write(buffer, 0, len); 210 total += len; 211 if (total > MAX_GRAVATAR_SIZE) { 212 LOG.warn("Gravatar exceeded maximum size: {}", url); 213 break; 214 } 215 } 216 217 out.flush(); 218 219 if (LOG.isDebugEnabled()) { 220 LOG.debug("Downloaded Gravatar: {}", url); 221 } 222 } 223 } 224 225}