001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2018 Michael N. Lipp
004 * 
005 * This program is free software; you can redistribute it and/or modify it 
006 * under the terms of the GNU Affero General Public License as published by 
007 * the Free Software Foundation; either version 3 of the License, or 
008 * (at your option) any later version.
009 * 
010 * This program is distributed in the hope that it will be useful, but 
011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
013 * for more details.
014 * 
015 * You should have received a copy of the GNU Affero General Public License along 
016 * with this program; if not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jgrapes.http;
020
021import java.io.IOException;
022import java.io.InputStream;
023import java.io.UnsupportedEncodingException;
024import java.net.JarURLConnection;
025import java.net.URI;
026import java.net.URISyntaxException;
027import java.net.URL;
028import java.net.URLConnection;
029import java.nio.file.FileSystemNotFoundException;
030import java.nio.file.Files;
031import java.nio.file.Path;
032import java.nio.file.Paths;
033import java.text.ParseException;
034import java.time.Instant;
035import java.time.temporal.ChronoField;
036import java.util.ArrayList;
037import java.util.List;
038import java.util.Optional;
039import java.util.function.Function;
040import java.util.jar.JarEntry;
041import java.util.regex.Pattern;
042import org.jdrupes.httpcodec.protocols.http.HttpConstants.HttpStatus;
043import org.jdrupes.httpcodec.protocols.http.HttpField;
044import org.jdrupes.httpcodec.protocols.http.HttpRequest;
045import org.jdrupes.httpcodec.protocols.http.HttpResponse;
046import org.jdrupes.httpcodec.types.Converters;
047import org.jdrupes.httpcodec.types.Directive;
048import org.jdrupes.httpcodec.types.MediaType;
049import org.jgrapes.core.Event;
050import org.jgrapes.http.events.Request;
051import org.jgrapes.http.events.Response;
052import org.jgrapes.io.IOSubchannel;
053import org.jgrapes.io.events.Output;
054import org.jgrapes.io.util.InputStreamPipeline;
055
056/**
057 * Provides methods that support the creation of a {@link Response} events.
058 */
059@SuppressWarnings("PMD.DataflowAnomalyAnalysis")
060public abstract class ResponseCreationSupport {
061
062    /** A default implementation for the max-age calculator. */
063    @SuppressWarnings("PMD.LongVariable")
064    public static final MaxAgeCalculator DEFAULT_MAX_AGE_CALCULATOR
065        = new DefaultMaxAgeCalculator();
066
067    /**
068     * Send a response to the given request with the given status code 
069     * and reason phrase, including a `text/plain` body with the status 
070     * code and reason phrase. 
071     *
072     * @param request the request
073     * @param channel for responding; events will be sent using
074     * {@link IOSubchannel#respond(org.jgrapes.core.Event)}
075     * @param statusCode the status code to send
076     * @param reasonPhrase the reason phrase to send
077     */
078    @SuppressWarnings("PMD.EmptyCatchBlock")
079    public static void sendResponse(HttpRequest request,
080            IOSubchannel channel, int statusCode, String reasonPhrase) {
081        HttpResponse response = request.response().get();
082        response.setStatusCode(statusCode).setReasonPhrase(reasonPhrase)
083            .setHasPayload(true).setField(
084                HttpField.CONTENT_TYPE,
085                MediaType.builder().setType("text", "plain")
086                    .setParameter("charset", "utf-8").build());
087        // Act like a sub-component, i.e. generate events that are
088        // handled by this HTTP server as if sent from a sub-component.
089        channel.respond(new Response(response));
090        try {
091            channel.respond(Output.from((statusCode + " " + reasonPhrase)
092                .getBytes("utf-8"), true));
093        } catch (UnsupportedEncodingException e) {
094            // Supported by definition
095        }
096    }
097
098    /**
099     * Shorthand for invoking 
100     * {@link #sendResponse(HttpRequest, IOSubchannel, int, String)}
101     * with a predefined HTTP status.
102     *
103     * @param request the request
104     * @param channel the channel
105     * @param status the status
106     */
107    public static void sendResponse(HttpRequest request,
108            IOSubchannel channel, HttpStatus status) {
109        sendResponse(request, channel, status.statusCode(),
110            status.reasonPhrase());
111    }
112
113    /**
114     * Creates and sends a response with static content. The content 
115     * is looked up by invoking the resolver with the path from the request.
116     * 
117     * The response includes a max-age header with a default value of
118     * 600. The value may be modified by specifying validity infos.
119     *
120     * @param request the request
121     * @param channel the channel
122     * @param resolver the resolver
123     * @param maxAgeCalculator the max age calculator, if `null`
124     * the default calculator is used.
125     * @return `true` if a response was sent
126     */
127    @SuppressWarnings({ "PMD.NcssCount",
128        "PMD.UseStringBufferForStringAppends" })
129    public static boolean sendStaticContent(
130            HttpRequest request, IOSubchannel channel,
131            Function<String, URL> resolver, MaxAgeCalculator maxAgeCalculator) {
132        String path = request.requestUri().getPath();
133        URL resourceUrl = resolver.apply(path);
134        ResourceInfo info;
135        URLConnection resConn;
136        InputStream resIn;
137        try {
138            if (resourceUrl == null) {
139                throw new IOException();
140            }
141            info = ResponseCreationSupport.resourceInfo(resourceUrl);
142            if (Boolean.TRUE.equals(info.isDirectory())) {
143                throw new IOException();
144            }
145            resConn = resourceUrl.openConnection();
146            resIn = resConn.getInputStream();
147        } catch (IOException e1) {
148            try {
149                if (!path.endsWith("/")) {
150                    path += "/";
151                }
152                path += "index.html";
153                resourceUrl = resolver.apply(path);
154                if (resourceUrl == null) {
155                    return false;
156                }
157                info = ResponseCreationSupport.resourceInfo(resourceUrl);
158                resConn = resourceUrl.openConnection();
159                resIn = resConn.getInputStream();
160            } catch (IOException e2) {
161                return false;
162            }
163        }
164        HttpResponse response = request.response().get();
165        response.setField(HttpField.LAST_MODIFIED,
166            Optional.ofNullable(info.getLastModifiedAt())
167                .orElseGet(() -> Instant.now()));
168
169        // Get content type and derive max age
170        MediaType mediaType = HttpResponse.contentType(
171            ResponseCreationSupport.uriFromUrl(resourceUrl));
172        setMaxAge(response,
173            (maxAgeCalculator == null ? DEFAULT_MAX_AGE_CALCULATOR
174                : maxAgeCalculator).maxAge(request, mediaType));
175
176        // Check if sending is really required.
177        Optional<Instant> modifiedSince = request
178            .findValue(HttpField.IF_MODIFIED_SINCE, Converters.DATE_TIME);
179        if (modifiedSince.isPresent() && info.getLastModifiedAt() != null
180            && !info.getLastModifiedAt().isAfter(modifiedSince.get())) {
181            response.setStatus(HttpStatus.NOT_MODIFIED);
182            channel.respond(new Response(response));
183        } else {
184            response.setContentType(mediaType);
185            response.setStatus(HttpStatus.OK);
186            channel.respond(new Response(response));
187            // Start sending content (Output events as resonses)
188            (new InputStreamPipeline(resIn, channel).suppressClose()).run();
189        }
190        return true;
191    }
192
193    /**
194     * Shorthand for invoking 
195     * {@link #sendStaticContent(HttpRequest, IOSubchannel, Function, MaxAgeCalculator)}
196     * with the {@link HttpRequest} from the event. Also sets the result
197     * of the event to `true` and invokes {@link Event#stop()} 
198     * if a response was sent.
199     *
200     * @param event the event
201     * @param channel the channel
202     * @param resolver the resolver
203     * @param maxAgeCalculator the max age calculator, if `null`
204     * the default calculator is used.
205     * @return `true` if a response was sent
206     * @throws ParseException the parse exception
207     */
208    public static boolean sendStaticContent(
209            Request.In event, IOSubchannel channel,
210            Function<String, URL> resolver, MaxAgeCalculator maxAgeCalculator) {
211        if (sendStaticContent(
212            event.httpRequest(), channel, resolver, maxAgeCalculator)) {
213            event.setResult(true);
214            event.stop();
215            return true;
216        }
217        return false;
218    }
219
220    /**
221     * Combines the known information about a resource.
222     */
223    public static class ResourceInfo {
224        public Boolean isDirectory;
225        public Instant lastModifiedAt;
226
227        /**
228         * @param isDirectory
229         * @param lastModifiedAt
230         */
231        public ResourceInfo(Boolean isDirectory, Instant lastModifiedAt) {
232            this.isDirectory = isDirectory;
233            this.lastModifiedAt = lastModifiedAt;
234        }
235
236        /**
237         * @return the isDirectory
238         */
239        public Boolean isDirectory() {
240            return isDirectory;
241        }
242
243        /**
244         * @return the lastModifiedAt
245         */
246        public Instant getLastModifiedAt() {
247            return lastModifiedAt;
248        }
249    }
250
251    /**
252     * Attempts to lookup the additional resource information for the
253     * given URL. 
254     * 
255     * If a {@link URL} references a file, it is easy to find out if 
256     * the resource referenced is a directory and to get its last 
257     * modification time. Getting the same information
258     * for a {@link URL} that references resources in a jar is a bit
259     * more difficult. This method handles both cases.
260     *
261     * @param resource the resource URL
262     * @return the resource info
263     */
264    @SuppressWarnings("PMD.EmptyCatchBlock")
265    public static ResourceInfo resourceInfo(URL resource) {
266        try {
267            Path path = Paths.get(resource.toURI());
268            return new ResourceInfo(Files.isDirectory(path),
269                Files.getLastModifiedTime(path).toInstant()
270                    .with(ChronoField.NANO_OF_SECOND, 0));
271        } catch (FileSystemNotFoundException | IOException
272                | URISyntaxException e) {
273            // Fall through
274        }
275        if ("jar".equals(resource.getProtocol())) {
276            try {
277                JarURLConnection conn
278                    = (JarURLConnection) resource.openConnection();
279                JarEntry entry = conn.getJarEntry();
280                return new ResourceInfo(entry.isDirectory(),
281                    entry.getLastModifiedTime().toInstant()
282                        .with(ChronoField.NANO_OF_SECOND, 0));
283            } catch (IOException e) {
284                // Fall through
285            }
286        }
287        try {
288            URLConnection conn = resource.openConnection();
289            long lastModified = conn.getLastModified();
290            if (lastModified != 0) {
291                return new ResourceInfo(null, Instant.ofEpochMilli(
292                    lastModified).with(ChronoField.NANO_OF_SECOND, 0));
293            }
294        } catch (IOException e) {
295            // Fall through
296        }
297        return new ResourceInfo(null, null);
298    }
299
300    /**
301     * Create a {@link URI} from a path. This is similar to calling
302     * `new URI(null, null, path, null)` with the {@link URISyntaxException}
303     * converted to a {@link IllegalArgumentException}.
304     * 
305     * @param path the path
306     * @return the uri
307     * @throws IllegalArgumentException if the string violates 
308     * RFC 2396
309     */
310    public static URI uriFromPath(String path) throws IllegalArgumentException {
311        try {
312            return new URI(null, null, path, null);
313        } catch (URISyntaxException e) {
314            throw new IllegalArgumentException(e);
315        }
316    }
317
318    /**
319     * Create a {@link URI} from a {@link URL}. This is similar to calling
320     * `url.toURI()` with the {@link URISyntaxException}
321     * converted to a {@link IllegalArgumentException}.
322     * 
323     * @param url the url
324     * @return the uri
325     * @throws IllegalArgumentException if the url violates RFC 2396
326     */
327    public static URI uriFromUrl(URL url) throws IllegalArgumentException {
328        try {
329            return url.toURI();
330        } catch (URISyntaxException e) {
331            throw new IllegalArgumentException(e);
332        }
333    }
334
335    /**
336     * Sets the cache control header in the given response.
337     *
338     * @param response the response
339     * @param maxAge the max age
340     * @return the value set
341     */
342    public static long setMaxAge(HttpResponse response, int maxAge) {
343        List<Directive> directives = new ArrayList<>();
344        directives.add(new Directive("max-age", maxAge));
345        response.setField(HttpField.CACHE_CONTROL, directives);
346        return maxAge;
347    }
348
349    /**
350     * Describes a calculator for the max-age property.
351     */
352    @FunctionalInterface
353    public interface MaxAgeCalculator {
354
355        /**
356         * Calculate a max age value for a response using the given 
357         * request and the media type of the repsonse.
358         *
359         * @param request the request, usually only the URI is
360         * considered for the calculation
361         * @param mediaType the media type of the response
362         * @return the max age value to be used in the response
363         */
364        int maxAge(HttpRequest request, MediaType mediaType);
365    }
366
367    /**
368     * DefaultMaxAgeCalculator provides an implementation that 
369     * tries to guess a good max age value by looking at the
370     * path of the requested resource. If the path contains
371     * the pattern "dash, followed by a number, followed by
372     * a dot and a number" it is assumed that the resource
373     * is versioned, i.e. its path changes if the resource
374     * changes. In this case a max age of one year is returned.
375     * In all other cases, a max age value of 60 (one minute)
376     * is returned.
377     */
378    public static class DefaultMaxAgeCalculator implements MaxAgeCalculator {
379
380        public static final Pattern VERSION_PATTERN
381            = Pattern.compile("-[0-9]+\\.[0-9]+");
382
383        @Override
384        public int maxAge(HttpRequest request, MediaType mediaType) {
385            if (VERSION_PATTERN.matcher(
386                request.requestUri().getPath()).find()) {
387                return 365 * 24 * 3600;
388            }
389            return 60;
390        }
391
392    }
393}