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}