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