001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2016-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.net.MalformedURLException;
023import java.net.URI;
024import java.nio.file.FileSystem;
025import java.nio.file.FileSystemNotFoundException;
026import java.nio.file.Files;
027import java.nio.file.Path;
028import java.nio.file.Paths;
029import java.nio.file.StandardOpenOption;
030import java.text.ParseException;
031import java.time.Instant;
032import java.time.temporal.ChronoField;
033import java.util.Arrays;
034import java.util.Optional;
035
036import org.jdrupes.httpcodec.protocols.http.HttpConstants.HttpStatus;
037import org.jdrupes.httpcodec.protocols.http.HttpField;
038import org.jdrupes.httpcodec.protocols.http.HttpResponse;
039import org.jdrupes.httpcodec.types.Converters;
040import org.jdrupes.httpcodec.types.MediaType;
041import org.jgrapes.core.Channel;
042import org.jgrapes.core.Component;
043import org.jgrapes.http.ResponseCreationSupport.MaxAgeCalculator;
044import org.jgrapes.http.annotation.RequestHandler;
045import org.jgrapes.http.events.Request;
046import org.jgrapes.http.events.Response;
047import org.jgrapes.io.IOSubchannel;
048import org.jgrapes.io.events.StreamFile;
049
050/**
051 * A dispatcher for requests for static content, usually files.
052 */
053public class StaticContentDispatcher extends Component {
054
055    private ResourcePattern resourcePattern;
056    private URI contentRoot;
057    private Path contentDirectory;
058    private MaxAgeCalculator maxAgeCalculator
059        = (request, mediaType) -> 365 * 24 * 3600;
060
061    /**
062     * Creates new dispatcher that tries to fulfill requests matching 
063     * the given resource pattern from the given content root.
064     * 
065     * An attempt is made to convert the content root to a {@link Path}
066     * in a {@link FileSystem}. If this fails, the content root is
067     * used as a URL against which requests are resolved and data
068     * is obtained by open an input stream from the resulting URL.
069     * In the latter case modification times aren't available. 
070     * 
071     * @param componentChannel this component's channel
072     * @param resourcePattern the pattern that requests must match 
073     * in order to be handled by this component 
074     * (see {@link ResourcePattern})
075     * @param contentRoot the location with content to serve 
076     * @see Component#Component(Channel)
077     */
078    public StaticContentDispatcher(Channel componentChannel,
079            String resourcePattern, URI contentRoot) {
080        super(componentChannel);
081        try {
082            this.resourcePattern = new ResourcePattern(resourcePattern);
083        } catch (ParseException e) {
084            throw new IllegalArgumentException(e);
085        }
086        try {
087            this.contentDirectory = Paths.get(contentRoot);
088        } catch (FileSystemNotFoundException e) {
089            this.contentRoot = contentRoot;
090        }
091        RequestHandler.Evaluator.add(this, "onGet", resourcePattern);
092    }
093
094    /**
095     * Creates a new component base with its channel set to
096     * itself.
097     * 
098     * @param resourcePattern the pattern that requests must match with to 
099     * be handled by this component 
100     * (see {@link ResourcePattern#matches(String, java.net.URI)})
101     * @param contentRoot the location with content to serve 
102     * @see Component#Component()
103     */
104    public StaticContentDispatcher(String resourcePattern, URI contentRoot) {
105        this(Channel.SELF, resourcePattern, contentRoot);
106    }
107
108    /**
109     * @return the maxAgeCalculator
110     */
111    public MaxAgeCalculator maxAgeCalculator() {
112        return maxAgeCalculator;
113    }
114
115    /**
116     * Sets the {@link MaxAgeCalculator} for generating the `Cache-Control` 
117     * (`max-age`) header of the response. The default max age calculator 
118     * used simply returns a max age of one year, since this component
119     * is intended to serve static content.
120     * 
121     * @param maxAgeCalculator the maxAgeCalculator to set
122     */
123    public void setMaxAgeCalculator(MaxAgeCalculator maxAgeCalculator) {
124        this.maxAgeCalculator = maxAgeCalculator;
125    }
126
127    /**
128     * Handles a `GET` request.
129     *
130     * @param event the event
131     * @param channel the channel
132     * @throws ParseException the parse exception
133     * @throws IOException Signals that an I/O exception has occurred.
134     */
135    @RequestHandler(dynamic = true)
136    public void onGet(Request.In.Get event, IOSubchannel channel)
137            throws ParseException, IOException {
138        int prefixSegs = resourcePattern.matches(event.requestUri());
139        if (prefixSegs < 0) {
140            return;
141        }
142        if (contentDirectory == null) {
143            getFromUri(event, channel, prefixSegs);
144        } else {
145            getFromFileSystem(event, channel, prefixSegs);
146        }
147    }
148
149    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
150    private boolean getFromFileSystem(Request.In.Get event,
151            IOSubchannel channel, int prefixSegs)
152            throws IOException, ParseException {
153        // Final wrapper for usage in closure
154        final Path[] assembly = new Path[] { contentDirectory };
155        Arrays.stream(event.requestUri().getPath().split("/"))
156            .skip(prefixSegs + 1)
157            .forEach(seg -> assembly[0] = assembly[0].resolve(seg));
158        Path resourcePath = assembly[0];
159        if (Files.isDirectory(resourcePath)) {
160            Path indexPath = resourcePath.resolve("index.html");
161            if (Files.isReadable(indexPath)) {
162                resourcePath = indexPath;
163            } else {
164                return false;
165            }
166        }
167        if (!Files.isReadable(resourcePath)) {
168            return false;
169        }
170
171        // Get content type
172        HttpResponse response = event.httpRequest().response().get();
173        MediaType mediaType = HttpResponse.contentType(resourcePath.toUri());
174
175        // Derive max-age
176        ResponseCreationSupport.setMaxAge(
177            response, maxAgeCalculator.maxAge(event.httpRequest(), mediaType));
178
179        // Check if sending is really required.
180        Instant lastModified = Files.getLastModifiedTime(resourcePath)
181            .toInstant().with(ChronoField.NANO_OF_SECOND, 0);
182        Optional<Instant> modifiedSince = event.httpRequest()
183            .findValue(HttpField.IF_MODIFIED_SINCE, Converters.DATE_TIME);
184        event.setResult(true);
185        event.stop();
186        if (modifiedSince.isPresent()
187            && !lastModified.isAfter(modifiedSince.get())) {
188            response.setStatus(HttpStatus.NOT_MODIFIED);
189            response.setField(HttpField.LAST_MODIFIED, lastModified);
190            channel.respond(new Response(response));
191        } else {
192            response.setContentType(mediaType);
193            response.setStatus(HttpStatus.OK);
194            response.setField(HttpField.LAST_MODIFIED, lastModified);
195            channel.respond(new Response(response));
196            fire(new StreamFile(resourcePath, StandardOpenOption.READ),
197                channel);
198        }
199        return true;
200    }
201
202    private boolean getFromUri(Request.In.Get event, IOSubchannel channel,
203            int prefixSegs) throws ParseException {
204        return ResponseCreationSupport.sendStaticContent(
205            event, channel, path -> {
206                try {
207                    return contentRoot.resolve(
208                        ResourcePattern.removeSegments(
209                            path, prefixSegs + 1))
210                        .toURL();
211                } catch (MalformedURLException e) {
212                    throw new IllegalArgumentException(e);
213                }
214            }, maxAgeCalculator);
215    }
216
217}